diff --git a/apps/backend/.dockerignore b/apps/backend/.dockerignore new file mode 100644 index 00000000..5171c540 --- /dev/null +++ b/apps/backend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log \ No newline at end of file diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 00000000..0f24be11 --- /dev/null +++ b/apps/backend/.env.example @@ -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= \ No newline at end of file diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile new file mode 100644 index 00000000..dff48ecc --- /dev/null +++ b/apps/backend/Dockerfile @@ -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"] diff --git a/apps/backend/certificate.html b/apps/backend/certificate.html new file mode 100644 index 00000000..26392dbf --- /dev/null +++ b/apps/backend/certificate.html @@ -0,0 +1,431 @@ + + + + + + + + + + Certificate + + + + + +
+
+ +

${orgName}

+
+ +
+

This certificate is awarded to

+

${studentName}

+
+ +
+

has succesfully completed training on

+

${courseName}

+
+ +
+
+

${dateIssued}

+

Date Issued

+
+ +
+

+

Signature

+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/backend/course.html b/apps/backend/course.html new file mode 100644 index 00000000..ca9560e8 --- /dev/null +++ b/apps/backend/course.html @@ -0,0 +1,182 @@ + + + + + + + + + + + Lesson Note + + + + + +
+

React js for Beginners

+

By TheSpace

+
+ +
+ +
+

+ 32 +

+

The Yams

+

By The Space

+
+ +
+
+

Lesson Note: Introduction and Counselling

+

Course: The Ultimate Guide to relocating to the UK via Study Path

+

Lesson Overview:

+

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.

+

Learning Objectives:

+

By the end of this lesson, students should be able to:

+
    +
  • Understand the goals and structure of the course.
  • +
  • Recognize the significance of counseling in the relocation process.
  • +
  • Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.
  • +
+

Lesson Content:

+
    +
  1. +

    Introduction to the Course

    +
      +
    • Briefly introduce the course title and explain its relevance to students' interests.
    • +
    • Highlight the main topics and modules covered throughout the course.
    • +
    +
  2. +
  3. +

    Importance of Counseling

    +
      +
    • Define the role of counseling in the relocation process.
    • +
    • Discuss how counseling can assist students in making informed decisions regarding their studies and + life in the UK.
    • +
    • Explain the benefits of seeking professional guidance during the relocation journey.
    • +
    +
  4. +
  5. +

    Steps for Relocating to the UK via Study Path

    +
      +
    • Provide an overview of the necessary steps involved in relocating to the UK for study purposes.
    • +
    • Discuss the importance of thorough planning, research, and documentation in the relocation process. +
    • +
    • Highlight key points such as visa applications, university admissions, accommodation arrangements, and + financial considerations.
    • +
    +
  6. +
  7. +

    Q&A and Discussion Time

    +
      +
    • Allocate time for students to ask questions, provide comments, or share personal experiences.
    • +
    • Encourage active participation and foster a supportive learning environment.
    • +
    +
  8. +
+

Conclusion:

+

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.

+ +
+ +
+

Extra Resources

+
+

Slide Link:

Open Here +
+ +

Video Link:

+ + +
+
+ + + +
+ + + \ No newline at end of file diff --git a/apps/backend/fly.toml b/apps/backend/fly.toml new file mode 100644 index 00000000..009283bc --- /dev/null +++ b/apps/backend/fly.toml @@ -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"] diff --git a/apps/backend/lesson.html b/apps/backend/lesson.html new file mode 100644 index 00000000..b31ff14c --- /dev/null +++ b/apps/backend/lesson.html @@ -0,0 +1,186 @@ + + + + + + + + + + + Lesson Note + + + + + +
+

React js for Beginners

+

By TheSpace

+
+ +
+ +
+

+ 32 +

+

The Yams

+

By The Space

+
+ +
+
+

Lesson Note: Introduction and Counselling

+

Course: The Ultimate Guide to relocating to the UK via Study Path

+

Lesson Overview:

+

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.

+

Learning Objectives:

+

By the end of this lesson, students should be able to:

+
    +
  • Understand the goals and structure of the course.
  • +
  • Recognize the significance of counseling in the relocation process.
  • +
  • Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.
  • +
+

Lesson Content:

+
    +
  1. +

    Introduction to the Course

    +
      +
    • Briefly introduce the course title and explain its relevance to students' interests.
    • +
    • Highlight the main topics and modules covered throughout the course.
    • +
    +
  2. +
  3. +

    Importance of Counseling

    +
      +
    • Define the role of counseling in the relocation process.
    • +
    • Discuss how counseling can assist students in making informed decisions regarding their studies and + life in the UK.
    • +
    • Explain the benefits of seeking professional guidance during the relocation journey.
    • +
    +
  4. +
  5. +

    Steps for Relocating to the UK via Study Path

    +
      +
    • Provide an overview of the necessary steps involved in relocating to the UK for study purposes.
    • +
    • Discuss the importance of thorough planning, research, and documentation in the relocation process. +
    • +
    • Highlight key points such as visa applications, university admissions, accommodation arrangements, and + financial considerations.
    • +
    +
  6. +
  7. +

    Q&A and Discussion Time

    +
      +
    • Allocate time for students to ask questions, provide comments, or share personal experiences.
    • +
    • Encourage active participation and foster a supportive learning environment.
    • +
    +
  8. +
+

Conclusion:

+

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.

+ +
+ +
+

Extra Resources

+
+

Slide Link:

Open Here +
+ +

Video Link:

+ + +
+
+ + + +
+ + + \ No newline at end of file diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts new file mode 100644 index 00000000..0e18d88c --- /dev/null +++ b/apps/backend/src/config/env.ts @@ -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); diff --git a/apps/backend/src/constants/index.ts b/apps/backend/src/constants/index.ts new file mode 100644 index 00000000..9db00bc9 --- /dev/null +++ b/apps/backend/src/constants/index.ts @@ -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; diff --git a/apps/backend/src/middlewares/auth.ts b/apps/backend/src/middlewares/auth.ts new file mode 100644 index 00000000..4c6d9aa2 --- /dev/null +++ b/apps/backend/src/middlewares/auth.ts @@ -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 " + 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 + ); + } +}; diff --git a/apps/backend/src/routes/course/katex.ts b/apps/backend/src/routes/course/katex.ts new file mode 100644 index 00000000..49f8d70e --- /dev/null +++ b/apps/backend/src/routes/course/katex.ts @@ -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 + ); + } +}); diff --git a/apps/backend/src/routes/mail.ts b/apps/backend/src/routes/mail.ts new file mode 100644 index 00000000..539886db --- /dev/null +++ b/apps/backend/src/routes/mail.ts @@ -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 + ); + } +}); diff --git a/apps/backend/src/services/mail.ts b/apps/backend/src/services/mail.ts new file mode 100644 index 00000000..2d46422e --- /dev/null +++ b/apps/backend/src/services/mail.ts @@ -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 { + 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 { + 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 + }; + } +} diff --git a/apps/backend/src/types/course/index.ts b/apps/backend/src/types/course/index.ts new file mode 100644 index 00000000..4ccb7062 --- /dev/null +++ b/apps/backend/src/types/course/index.ts @@ -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; + +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; + +export const ZCourseDownloadPresignedUrl = z.object({ + keys: z.array(z.string()).min(1, 'File names array is required') +}); + +export type TCourseDownloadPresignedUrl = z.infer; diff --git a/apps/backend/src/types/course/lesson.ts b/apps/backend/src/types/course/lesson.ts new file mode 100644 index 00000000..66f24905 --- /dev/null +++ b/apps/backend/src/types/course/lesson.ts @@ -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; + +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; diff --git a/apps/backend/src/types/mail.ts b/apps/backend/src/types/mail.ts new file mode 100644 index 00000000..78116183 --- /dev/null +++ b/apps/backend/src/types/mail.ts @@ -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; diff --git a/apps/backend/src/utils/auth/validate-user.ts b/apps/backend/src/utils/auth/validate-user.ts new file mode 100644 index 00000000..9ea20f2f --- /dev/null +++ b/apps/backend/src/utils/auth/validate-user.ts @@ -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 { + 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; +} diff --git a/apps/backend/src/utils/certificate.ts b/apps/backend/src/utils/certificate.ts new file mode 100644 index 00000000..b9a40eb9 --- /dev/null +++ b/apps/backend/src/utils/certificate.ts @@ -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 = ` + + + + Certificate of Completion + + +
+
+ +

Certificate of Completion

+
+
+

${studentName}

+

${courseName}

+

${courseDescription}

+
+ +
+ + + `; + + 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; +} diff --git a/apps/backend/src/utils/cloudflare.ts b/apps/backend/src/utils/cloudflare.ts new file mode 100644 index 00000000..9d480803 --- /dev/null +++ b/apps/backend/src/utils/cloudflare.ts @@ -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'); + } +}; diff --git a/apps/backend/src/utils/course.ts b/apps/backend/src/utils/course.ts new file mode 100644 index 00000000..200b2261 --- /dev/null +++ b/apps/backend/src/utils/course.ts @@ -0,0 +1,170 @@ +import type { TCourseDownloadContent } from '$src/types/course'; +import { marked } from 'marked'; + +function getHtmlTemplate(body: string): string { + return ` + + + + + + + + + + Lesson Note + + + + + ${body} + + + `; +} + +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 ` +
+

+ ${lessonNumber} +

+

${lessonTitle}

+

By ${orgName}

+
+ +
+
+ ${noteHtml} +
+ +
+ ${ + showExtraResources + ? ` +
+

Extra Resources

+ ${ + slideUrl + ? `
+

Slide Link:

Open Here +
` + : '' + } + ${ + video && video.length > 0 + ? ` +

Video Link:

+
+
    + ${video + .map( + (videoData, index) => ` +
  1. ${videoData}
  2. + ` + ) + .join('')} +
+
+ ` + : '' + } +
+ ` + : '' + } +
+
+ `; +} + +function getCourseBody({ + courseTitle, + orgName, + orgTheme, + lessons +}: TCourseDownloadContent): string { + return ` +
+

${courseTitle}

+

${orgName}

+
+
+ ${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme)).join('')} +
+ `; +} + +async function generateSinglePdfFromHtml( + htmlContent: string, + courseTitle: string, + orgTheme: string +): Promise { + throw 'Download disabled'; +} + +export async function generateCoursePdf(params: TCourseDownloadContent): Promise { + const body = getCourseBody(params); + const html = getHtmlTemplate(body); + console.log(html); + return await generateSinglePdfFromHtml(html, params.courseTitle, params.orgTheme); +} diff --git a/apps/backend/src/utils/email.ts b/apps/backend/src/utils/email.ts new file mode 100644 index 00000000..3a52bf81 --- /dev/null +++ b/apps/backend/src/utils/email.ts @@ -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 => { + 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; + } +}; diff --git a/apps/backend/src/utils/genUniqueId.ts b/apps/backend/src/utils/genUniqueId.ts new file mode 100644 index 00000000..b45b91f3 --- /dev/null +++ b/apps/backend/src/utils/genUniqueId.ts @@ -0,0 +1,3 @@ +export default function genUniqueId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); +} diff --git a/apps/backend/src/utils/lesson.ts b/apps/backend/src/utils/lesson.ts new file mode 100644 index 00000000..4f4c6241 --- /dev/null +++ b/apps/backend/src/utils/lesson.ts @@ -0,0 +1,161 @@ +import type { TLessonDownloadContent } from '$src/types/course/lesson'; +import { marked } from 'marked'; + +function getHtmlTemplate(body: string): string { + return ` + + + + + + + + + + Lesson Note + + + + + ${body} + + + `; +} + +function getLessonBody({ + title, + number, + orgName, + note, + slideUrl, + video, + courseTitle +}: TLessonDownloadContent): string { + const noteHtml = marked.parse(note); + const showExtraResources = slideUrl || (video && video.length > 0); + return ` +
+
+

+ ${number} +

+

${title}

+

By ${orgName}

+
+ +
+
+ ${noteHtml} +
+ +
+ ${ + showExtraResources + ? ` +
+

Extra Resources

+ ${ + slideUrl + ? `
+

Slide Link:

Open Here +
` + : '' + } + ${ + video && video.length > 0 + ? ` +

Video Link:

+
+
    + ${video + .map( + (videoData, index) => ` +
  1. ${videoData}
  2. + ` + ) + .join('')} +
+
+ ` + : '' + } +
+ ` + : '' + } +
+
+ +
+ `; +} + +async function generateSinglePdfFromHtml( + htmlContent: string, + courseTitle: string +): Promise { + throw 'Download disabled'; +} + +export async function generateLessonPdf(params: TLessonDownloadContent): Promise { + const body = getLessonBody(params); + const html = getHtmlTemplate(body); + console.log(html); + return await generateSinglePdfFromHtml(html, params.courseTitle); +} diff --git a/apps/backend/src/utils/mail.ts b/apps/backend/src/utils/mail.ts new file mode 100644 index 00000000..99525b6e --- /dev/null +++ b/apps/backend/src/utils/mail.ts @@ -0,0 +1,213 @@ +interface FromData { + name: string; + email: string; +} + +// format: "ClassroomIO Developers (via 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 => + ` + + + + + + + + + + + +
+ + ClassroomIO Logo +
+
+ ${content} +
+ + + + + +`; diff --git a/apps/backend/src/utils/s3.ts b/apps/backend/src/utils/s3.ts new file mode 100644 index 00000000..56b56d05 --- /dev/null +++ b/apps/backend/src/utils/s3.ts @@ -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; + +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' }; + } +} diff --git a/apps/backend/src/utils/supabase.ts b/apps/backend/src/utils/supabase.ts new file mode 100644 index 00000000..cb0e461f --- /dev/null +++ b/apps/backend/src/utils/supabase.ts @@ -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; +} diff --git a/apps/backend/src/utils/upload.ts b/apps/backend/src/utils/upload.ts new file mode 100644 index 00000000..99cd0198 --- /dev/null +++ b/apps/backend/src/utils/upload.ts @@ -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}`; +} diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts new file mode 100644 index 00000000..f29f8b2a --- /dev/null +++ b/apps/backend/vitest.config.ts @@ -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/'] + } + } +}); diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/Index.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/Index.svelte new file mode 100644 index 00000000..80878f4c --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/Index.svelte @@ -0,0 +1,54 @@ + + +
+
+

+ {$t('course.navItem.lessons.materials.tabs.video.add_video.add_by')} +

+ {#each tabs as item (item.value)} + + {/each} +
+
+ {#if currentTab === 1} + + {:else if currentTab === 2} + + {:else} + + {/if} +
+
diff --git a/apps/dashboard/src/lib/components/Navigation/AuthButtons.svelte b/apps/dashboard/src/lib/components/Navigation/AuthButtons.svelte new file mode 100644 index 00000000..4c7e68a5 --- /dev/null +++ b/apps/dashboard/src/lib/components/Navigation/AuthButtons.svelte @@ -0,0 +1,47 @@ + + +
+
  • +
    + +
    +
  • + + {#if !disableSignup} +
  • +
    + +
    +
  • + {/if} +
    diff --git a/apps/dashboard/src/lib/components/Navigation/CustomLinks.svelte b/apps/dashboard/src/lib/components/Navigation/CustomLinks.svelte new file mode 100644 index 00000000..9ff56551 --- /dev/null +++ b/apps/dashboard/src/lib/components/Navigation/CustomLinks.svelte @@ -0,0 +1,31 @@ + + +{#if customLinks} + +{/if} diff --git a/apps/dashboard/src/lib/components/Navigation/Logo.svelte b/apps/dashboard/src/lib/components/Navigation/Logo.svelte new file mode 100644 index 00000000..bfbfcf48 --- /dev/null +++ b/apps/dashboard/src/lib/components/Navigation/Logo.svelte @@ -0,0 +1,25 @@ + + + diff --git a/apps/dashboard/src/lib/components/Navigation/MobileMenu.svelte b/apps/dashboard/src/lib/components/Navigation/MobileMenu.svelte new file mode 100644 index 00000000..e834e081 --- /dev/null +++ b/apps/dashboard/src/lib/components/Navigation/MobileMenu.svelte @@ -0,0 +1,100 @@ + + + + + +{#if mobileMenuOpen} +
    + + +
    +
    +

    Menu

    + +
    + + +
    +
    +{/if} + + diff --git a/apps/dashboard/src/lib/components/Navigation/index.svelte b/apps/dashboard/src/lib/components/Navigation/index.svelte index 72da91b6..6e6e9cbe 100644 --- a/apps/dashboard/src/lib/components/Navigation/index.svelte +++ b/apps/dashboard/src/lib/components/Navigation/index.svelte @@ -1,119 +1,81 @@ - diff --git a/apps/dashboard/src/lib/components/Navigation/types.ts b/apps/dashboard/src/lib/components/Navigation/types.ts new file mode 100644 index 00000000..c15ba4f0 --- /dev/null +++ b/apps/dashboard/src/lib/components/Navigation/types.ts @@ -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; +}; diff --git a/apps/dashboard/src/lib/components/Org/LandingPage/index.svelte b/apps/dashboard/src/lib/components/Org/LandingPage/index.svelte index 3e663748..d432c9ef 100644 --- a/apps/dashboard/src/lib/components/Org/LandingPage/index.svelte +++ b/apps/dashboard/src/lib/components/Org/LandingPage/index.svelte @@ -177,6 +177,13 @@ {:else}
    + {#if $landingPageSettings.header.show}
    - -
    {#if $landingPageSettings.header.banner.show}
    diff --git a/apps/dashboard/src/lib/components/Org/Settings/LandingpageSettings.svelte b/apps/dashboard/src/lib/components/Org/Settings/LandingpageSettings.svelte index 13821b9f..59958dbf 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/LandingpageSettings.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/LandingpageSettings.svelte @@ -27,6 +27,7 @@ let isSaving = false; let creatingNewQuestion = false; + let creatingNewCustomLink = false; let hasUnsavedChanges = false; let widgetKey = ''; const banner = [ @@ -40,6 +41,12 @@ content: '' }; + let newCustomLink = { + label: '', + url: '', + openInNewTab: false + }; + function widgetControl(key: string) { widgetKey = key; $handleOpenWidget.open = true; @@ -77,6 +84,40 @@ $landingPageSettings.faq.questions = filteredFaq; } + function createNewCustomLink() { + newCustomLink = { + label: '', + url: '', + openInNewTab: false + }; + creatingNewCustomLink = true; + } + + function saveNewCustomLink() { + if (newCustomLink.label !== '' && newCustomLink.url !== '') { + $landingPageSettings.customLinks.links = [ + ...$landingPageSettings.customLinks.links, + { + id: new Date().getTime(), + label: newCustomLink.label, + url: newCustomLink.url, + openInNewTab: newCustomLink.openInNewTab + } + ]; + creatingNewCustomLink = false; + } + } + + function cancelNewCustomLink() { + creatingNewCustomLink = false; + } + + function deleteCustomLink(id: number) { + let customLinks = $landingPageSettings.customLinks.links; + const filteredCustomLinks = customLinks.filter((link) => link.id !== id); + $landingPageSettings.customLinks.links = filteredCustomLinks; + } + const checkPrefix = (inputValue: string) => { if (!inputValue) return; @@ -103,7 +144,7 @@ const message = error?.message || 'snackbar.lms.error.try_again'; snackbar.error(`snackbar.lms.error.update ${message}`); } else { - $currentOrg.landingpage = $landingPageSettings; + $currentOrg.landingpage = $landingPageSettings as any; snackbar.success('snackbar.success_update'); hasUnsavedChanges = false; } @@ -124,7 +165,7 @@ $landingPageSettings = { ...landingpage - }; + } as OrgLandingPageJson; } } $: setDefault($currentOrg?.landingpage as unknown as OrgLandingPageJson); @@ -578,6 +619,99 @@ + + {$t('settings.landing_page.custom_links.heading')} + + {$t('settings.landing_page.custom_links.hide_links')} + {$t('settings.landing_page.custom_links.show_links')} + + + +

    + {$t('settings.landing_page.custom_links.description')} +

    + + {#each $landingPageSettings.customLinks.links as link (link.id)} +
    +
    + + +
    +
    + + deleteCustomLink(link.id)}> + + +
    +
    + {/each} + + {#if creatingNewCustomLink} +
    +
    + + +
    +
    + +
    + + +
    +
    +
    + {:else} + + {/if} +
    +
    + {$t('settings.landing_page.footer.heading')} @@ -640,7 +774,7 @@ > - +
    diff --git a/apps/dashboard/src/lib/components/Org/Settings/store.ts b/apps/dashboard/src/lib/components/Org/Settings/store.ts index 84775df1..ba307d9a 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/store.ts +++ b/apps/dashboard/src/lib/components/Org/Settings/store.ts @@ -17,7 +17,7 @@ export interface OrgLandingPageJson { type: string; show: boolean; }; - background?: { + background: { image: string; show: boolean; }; @@ -59,6 +59,15 @@ export interface OrgLandingPageJson { buttonLabel: string; show: boolean; }; + customLinks: { + show: boolean; + links: Array<{ + id: number; + label: string; + url: string; + openInNewTab: boolean; + }>; + }; footer: { facebook: string; instagram: string; @@ -145,6 +154,35 @@ export const landingPageSettings: Writable = writable({ buttonLabel: 'Subscribe', show: true }, + customLinks: { + show: true, + links: [ + { + id: 1, + label: 'About Us', + url: '#aboutus', + openInNewTab: false + }, + { + id: 2, + label: 'Courses', + url: '#courses', + openInNewTab: false + }, + { + id: 3, + label: 'FAQ', + url: '#faq', + openInNewTab: false + }, + { + id: 4, + label: 'Contact Us', + url: '#contact', + openInNewTab: false + } + ] + }, footer: { facebook: '#', instagram: '#', diff --git a/apps/dashboard/src/lib/utils/constants/orgLandingPageJson.json b/apps/dashboard/src/lib/utils/constants/orgLandingPageJson.json index cf146e11..66eec206 100644 --- a/apps/dashboard/src/lib/utils/constants/orgLandingPageJson.json +++ b/apps/dashboard/src/lib/utils/constants/orgLandingPageJson.json @@ -60,6 +60,35 @@ "buttonLabel": "Subscribe", "show": true }, + "customLinks": { + "show": true, + "links": [ + { + "id": 1, + "label": "About Us", + "url": "#about-us", + "openInNewTab": false + }, + { + "id": 2, + "label": "Courses", + "url": "#courses", + "openInNewTab": false + }, + { + "id": 3, + "label": "FAQ", + "url": "#faq", + "openInNewTab": false + }, + { + "id": 4, + "label": "Contact Us", + "url": "#contact", + "openInNewTab": false + } + ] + }, "footer": { "facebook": "#", "instagram": "#", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index bf7c58d7..f8dfb165 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -1286,6 +1286,20 @@ "title": "Hintergrundbild", "show_background": "Hintergrund anzeigen", "hide_background": "Hintergrund ausblenden" + }, + "custom_links": { + "heading": "Benutzerdefinierte Links", + "hide_links": "Links verbergen", + "show_links": "Links anzeigen", + "description": "Fügen Sie benutzerdefinierte Navigationslinks hinzu, die in der Header -Navigationsleiste angezeigt werden.", + "label": "Etikett", + "url": "URL", + "label_placeholder": "z. B. über uns", + "url_placeholder": "z. B. #über- oder https://example.com", + "new_tab": "Offen in neuer Registerkarte", + "save": "Speichern", + "cancel": "Stornieren", + "add": "Neuen Link hinzufügen" } }, "organization": { diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index ed2e130a..8b3efc8a 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -226,6 +226,21 @@ "twitter": "Twitter", "linkedin": "LinkedIn" }, + + "custom_links": { + "heading": "Custom Links", + "hide_links": "Hide Links", + "show_links": "Show Links", + "description": "Add custom navigation links that will appear in the header navigation bar.", + "label": "Label", + "url": "URL", + "label_placeholder": "e.g., About Us", + "url_placeholder": "e.g., #about-us or https://example.com", + "new_tab": "Open in new tab", + "save": "Save", + "cancel": "Cancel", + "add": "Add New Link" + }, "save_changes": "Save Changes" }, "integrations": { diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index f2881447..72290269 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -1286,6 +1286,20 @@ "title": "Imagen de fondo", "show_background": "Mostrar fondo", "hide_background": "Ocultar fondo" + }, + "custom_links": { + "heading": "Enlaces personalizados", + "hide_links": "Ocultar enlaces", + "show_links": "Mostrar enlaces", + "description": "Agregue enlaces de navegación personalizados que aparecerán en la barra de navegación del encabezado.", + "label": "Etiqueta", + "url": "Url", + "label_placeholder": "por ejemplo, sobre nosotros", + "url_placeholder": "por ejemplo, #About-US o https://example.com", + "new_tab": "Abrir en una nueva pestaña", + "save": "Ahorrar", + "cancel": "Cancelar", + "add": "Agregar nuevo enlace" } }, "organization": { diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 416dacdb..e1be19d3 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -1286,6 +1286,20 @@ "title": "Image d'arrière-plan", "show_background": "Afficher l'arrière-plan", "hide_background": "Masquer l'arrière-plan" + }, + "custom_links": { + "heading": "Liens personnalisés", + "hide_links": "Masquer les liens", + "show_links": "Montrer des liens", + "description": "Ajoutez des liens de navigation personnalisés qui apparaîtront dans la barre de navigation d'en-tête.", + "label": "Étiquette", + "url": "URL", + "label_placeholder": "par exemple, à propos de nous", + "url_placeholder": "par exemple, # à peu près ou https://example.com", + "new_tab": "Ouvert dans un nouvel onglet", + "save": "Sauvegarder", + "cancel": "Annuler", + "add": "Ajouter un nouveau lien" } }, "organization": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 75f988e2..eca58688 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -1286,6 +1286,20 @@ "title": "पृष्ठभूमि छवि", "show_background": "पृष्ठभूमि दिखाएँ", "hide_background": "पृष्ठभूमि छिपाएँ" + }, + "custom_links": { + "heading": "कस्टम लिंक", + "hide_links": "लिंक छिपाएं", + "show_links": "लिंक दिखाएं", + "description": "हेडर नेविगेशन बार में दिखाई देने वाले कस्टम नेविगेशन लिंक जोड़ें।", + "label": "लेबल", + "url": "यूआरएल", + "label_placeholder": "जैसे, हमारे बारे में", + "url_placeholder": "उदा।, #के बारे में या https://example.com", + "new_tab": "वेब टेब में खोलें", + "save": "बचाना", + "cancel": "रद्द करना", + "add": "नया लिंक जोड़ें" } }, "organization": { diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 6584a33a..3f9ec13e 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -1402,6 +1402,20 @@ "title": "Obraz tła", "show_background": "Pokaż tło", "hide_background": "Ukryj tło" + }, + "custom_links": { + "heading": "Niestandardowe linki", + "hide_links": "Ukryj linki", + "show_links": "Pokaż linki", + "description": "Dodaj niestandardowe linki nawigacyjne, które pojawią się na pasku nawigacji nagłówka.", + "label": "Etykieta", + "url": "URL", + "label_placeholder": "np. O nas", + "url_placeholder": "np. #About-US lub https://example.com", + "new_tab": "Otwórz w nowej zakładce", + "save": "Ratować", + "cancel": "Anulować", + "add": "Dodaj nowy link" } }, "organization": { diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index ab1b72fb..fee2e14a 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -1286,6 +1286,20 @@ "title": "Imagem de fundo", "show_background": "Mostrar plano de fundo", "hide_background": "Ocultar plano de fundo" + }, + "custom_links": { + "heading": "Links personalizados", + "hide_links": "Ocultar links", + "show_links": "Mostre links", + "description": "Adicione links de navegação personalizados que aparecerão na barra de navegação do cabeçalho.", + "label": "Rótulo", + "url": "Url", + "label_placeholder": "por exemplo, sobre nós", + "url_placeholder": "por exemplo, #sobre-us ou https://example.com", + "new_tab": "Abrir na nova guia", + "save": "Salvar", + "cancel": "Cancelar", + "add": "Adicione um novo link" } }, "organization": { diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index aa86b3b6..b05f6a97 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -1286,6 +1286,20 @@ "title": "Фоновое изображение", "show_background": "Показать фон", "hide_background": "Скрыть фон" + }, + "custom_links": { + "heading": "Пользовательские ссылки", + "hide_links": "Скрыть ссылки", + "show_links": "Показать ссылки", + "description": "Добавьте пользовательские навигационные ссылки, которые появятся в панели навигации заголовка.", + "label": "Этикетка", + "url": "URL", + "label_placeholder": "например, о нас", + "url_placeholder": "Например, #about-us или https://example.com", + "new_tab": "Открыто в новой вкладке", + "save": "Сохранять", + "cancel": "Отмена", + "add": "Добавить новую ссылку" } }, "organization": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 0ce79fca..2ba50658 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -1286,6 +1286,20 @@ "title": "Hình nền", "show_background": "Hiển thị nền", "hide_background": "Ẩn nền" + }, + "custom_links": { + "heading": "Liên kết tùy chỉnh", + "hide_links": "Ẩn liên kết", + "show_links": "Hiển thị liên kết", + "description": "Thêm các liên kết điều hướng tùy chỉnh sẽ xuất hiện trong thanh điều hướng tiêu đề.", + "label": "Nhãn", + "url": "URL", + "label_placeholder": "ví dụ: về chúng tôi", + "url_placeholder": "ví dụ: #abing-us hoặc https://example.com", + "new_tab": "Mở trong tab mới", + "save": "Cứu", + "cancel": "Hủy bỏ", + "add": "Thêm liên kết mới" } }, "organization": {