feat: optimize docker compose for dashboard and api (#578)

* chore: use turbo for build

* fix docker compose file and remove backend

* chore: update .dockerignore to include additional files and directories for improved build management

* copy everything in both docke filed

* refactor: streamline Dockerfile to copy entire dashboard app and remove unnecessary node_modules references

* update api to use turbo to start

* run ls la for docker files

* update cmd commands

* update cmds to use node

* fix: add redis url to docker compose

* fix: reduce memory allocation for dashboard build in Dockerfile

* debug dashboard build

* update api docker file

* update dashboard

* add tsconfig

* chore: add echo to dockerfile

* fix: change echo to run

* chore: install pnpm before switching to non-root user in Dockerfile

* refactor: update start commands in package.json and Dockerfiles to use pnpm

* install turbo

* use turbo 1.10.16

* update turbo config

* fix: update CMD in Dockerfile to run the API directly using Node.js

* change

* add commands to turbo.json

* copy over turbo

* add workspace

* fix: update start commands in package.json to specify ports for dashboard and API

* publish docker images

* add dev branch

* update compose

* build only for amd64 platform

* refactor: update docker-compose to use pre-built images and remove build context

* coolify-dockertest

* point to latest

* chore: update docker-compose to use specific version tags for API and dashboard images

* remove envs for docker file

* 0.1.3

* use dynamic env

* 0.1.4

* add PUBLIC_IS_SELFHOSTED=true

* chore: update docker-compose to use version 0.1.5 for API and dashboard images

* use static for PUBLIC_IS_SELFHOSTED

* disable cache for tag

* use 0.1.7

* add PUBLIC_IS_SELFHOSTED=true

* comment out caching

* update docker-compose to use version 0.1.8 for API and dashboard images

* update api port

* use tag 0.1.9

* add cache

* use latest tag in compose file
This commit is contained in:
Rotimi Best
2025-10-11 13:27:50 +01:00
committed by GitHub
parent 27ee51155e
commit 168866c81b
44 changed files with 848 additions and 2388 deletions
+156 -9
View File
@@ -1,18 +1,165 @@
Dockerfile # Docker files
Dockerfile*
.dockerignore .dockerignore
# Git
.git .git
.gitignore .gitignore
.gitattributes .gitattributes
.gitmodules
# Documentation
README.md README.md
.npmrc CHANGELOG.md
CONTRIBUTING.md
CODE_OF_CONDUCT.md
LICENSE
*.md
# IDE and Editor files
.vscode
.idea
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Node.js
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm
.yarn-integrity
.pnpm-debug.log*
# Build outputs
**/dist
**/build
**/.next
**/.svelte-kit
**/coverage
**/.nyc_output
**/test-results
**/playwright-report
**/playwright/.cache
# Dependencies
**/node_modules
**/.pnpm-store
.pnpm-store/*
# Environment files
**/.env
**/.env.local
**/.env.development
**/.env.test
**/.env.production
**/.env.staging
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Turbo
**/.turbo
# Testing
**/cypress
**/cypress.config.js
**/cypress.config.ts
**/test-results
**/playwright-report
**/playwright/.cache
# Package managers (keep pnpm-lock.yaml for builds)
package-lock.json
yarn.lock
# Config files that aren't needed in container
.prettierrc .prettierrc
.eslintrc.cjs .eslintrc.cjs
.graphqlrc .graphqlrc
.editorconfig .editorconfig
.vscode .npmrc
node_modules
build
package
**/.env
.pnpm-store/*
+71
View File
@@ -0,0 +1,71 @@
name: Publish Docker Images
on:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Version tag (e.g., v1.0.0)"
required: false
default: "latest"
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'classroomio' }}
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: api
dockerfile: ./docker/Dockerfile.api
image: api
- name: dashboard
dockerfile: ./docker/Dockerfile.dashboard
image: dashboard
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKERHUB_USERNAME }}/${{ matrix.image }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push ${{ matrix.name }}
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.DOCKERHUB_USERNAME }}/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=${{ env.DOCKERHUB_USERNAME }}/${{ matrix.image }}:buildcache,mode=max
platforms: linux/amd64
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
-85
View File
@@ -1,85 +0,0 @@
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
ARG ZOHO_TOKEN
ARG CLOUDFLARE_RENDERING_API_KEY
ARG OPENAPI_URL
RUN mkdir /app
WORKDIR /app
# Install pnpm and healthcheck tools
RUN npm install -g pnpm && \
apt-get update && \
apt-get install -y curl wget && \
rm -rf /var/lib/apt/lists/*
# 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 \
CLOUDFLARE_RENDERING_API_KEY=$CLOUDFLARE_RENDERING_API_KEY \
SMTP_HOST=$SMTP_HOST \
SMTP_USER=$SMTP_USER \
SMTP_PASSWORD=$SMTP_PASSWORD \
SMTP_PORT=$SMTP_PORT \
SMTP_SENDER=$SMTP_SENDER \
ZOHO_TOKEN=$ZOHO_TOKEN \
OPENAPI_URL=$OPENAPI_URL \
PORT=3002
RUN pnpm build
EXPOSE 3002
# Add healthcheck
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3002 || exit 1
CMD ["pnpm", "start"]
-2
View File
@@ -1,2 +0,0 @@
node_modules
npm-debug.log
-20
View File
@@ -1,20 +0,0 @@
## Supabase - DB
PUBLIC_SUPABASE_ANON_KEY=
PUBLIC_SUPABASE_URL=
## Cloudflare - Video uploads
# Not required - https://cdn.example.com
CLOUDFLARE_BUCKET_DOMAIN=
CLOUDFLARE_ACCESS_KEY=
CLOUDFLARE_SECRET_ACCESS_KEY=
CLOUDFLARE_ACCOUNT_ID=
# Email
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
SMTP_PORT=
# Sentry DNS
SENTRY_DNS=
-71
View File
@@ -1,71 +0,0 @@
FROM node:slim AS app
# Add build arguments
ARG PUBLIC_SUPABASE_ANON_KEY
ARG PUBLIC_SUPABASE_URL
ARG CLOUDFLARE_BUCKET_DOMAIN
ARG CLOUDFLARE_ACCESS_KEY
ARG CLOUDFLARE_SECRET_ACCESS_KEY
ARG CLOUDFLARE_ACCOUNT_ID
ARG CLOUDFLARE_BUCKET_ID
ARG SMTP_HOST
ARG SMTP_USER
ARG SMTP_PASSWORD
ARG SMTP_PORT
ARG SMTP_SENDER
RUN mkdir /app
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Install chrome stable from sources, then remove it. Why? The
# npm install of `puppeteer` brings its own bundle chromium build,
# and puppeteer releases are only guaranteed to work with that version.
#
# But the bundled chromium implicitly needs a bunch of shared libs on
# the host. It's a little tedious to find and maintain that list; but
# the official apt distro of `google-chrome-stable` should bring the right
# set along. So do that, but immediately uninstall (to free up layer space).
#
# This is a little brittle, since the puppeteer chrome could in theory diverge
# from the official apt chrome's shared lib deps. But it works..
# RUN apt-get update \
# && apt-get install curl gnupg -y \
# && curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
# && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
# && apt-get update \
# && apt-get install google-chrome-stable -y --no-install-recommends \
# && apt-get remove google-chrome-stable -y \
# && rm -rf /var/lib/apt/lists/*
# Add emoji fonts.
# Source: https://gist.github.com/win0err/9d8c7f0feabdfe8a4c9787b02c79ac51
# RUN mkdir ~/.fonts/ && \
# wget https://github.com/samuelngs/apple-emoji-linux/releases/download/ios-15.4/AppleColorEmoji.ttf -O ~/.fonts/AppleColorEmoji.ttf
COPY ./package.json /app
RUN pnpm install
COPY . /app
# Set environment variables from build args
ENV NODE_ENV=production \
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
CLOUDFLARE_BUCKET_DOMAIN=$CLOUDFLARE_BUCKET_DOMAIN \
CLOUDFLARE_ACCESS_KEY=$CLOUDFLARE_ACCESS_KEY \
CLOUDFLARE_SECRET_ACCESS_KEY=$CLOUDFLARE_SECRET_ACCESS_KEY \
CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \
CLOUDFLARE_BUCKET_ID=$CLOUDFLARE_BUCKET_ID \
SMTP_HOST=$SMTP_HOST \
SMTP_USER=$SMTP_USER \
SMTP_PASSWORD=$SMTP_PASSWORD \
SMTP_PORT=$SMTP_PORT \
PORT=3002
RUN pnpm build
EXPOSE 3002
CMD ["pnpm", "start"]
-431
View File
@@ -1,431 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com"></script>
<title>Certificate</title>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
body {
position: relative;
}
</style>
</head>
<body>
<img
src="http://localhost:3002/blue-lined-background.png"
alt=""
class="absolute top-0 left-0 w-full h-[100vh] z-10"
/>
<div class="absolute z-20 left-[5%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">${courseName}</h2>
</div>
<div class="flex border justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">${courseDescription}</footer>
</div>
<!-- plain blueProfessionalBadgePattern -->
<!-- <img
src="http://localhost:3002/blue-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="text-lg font-bold mt-[9%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">Usman Elebute Ayomide</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!-- plain purpleProfessionalBadgePattern -->
<!-- <img
src="http://localhost:3002/purple-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="text-lg font-bold mt-[9%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-fuchsia-800">Usman Elebute Ayomide</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!------------------------------------------------------- new secton ---------------------------------------------------- -->
<!-- PurpleProfessionalBadge certificate -->
<!-- <img
src="http://localhost:3002/purple-lined-background.png"
alt=""
class="absolute -top-1 left-0 w-full z-10"
/>
<div class="absolute z-20 left-[10%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-[#95449A]">Usman Elebute Ayomide</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="pl-10 flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!-- BlueProfessionalBadge certificate -->
<!-- <img
src="http://localhost:3002/blue-lined-background.png"
alt=""
class="absolute -top-1 left-0 w-full z-10"
/>
<div class="absolute z-20 left-[10%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">${courseName}</h2>
</div>
<div class="pl-10 flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">${courseDescription}</footer>
</div> -->
<!-- --------------------- old certificates ------------------------- -->
<!-- <div
class="flex items-center justify-center border border-dashed border-blue-400 h-full w-full"
>
<div class="h-full flex border-l-8 border-blue-800">
<div class="h-full w-full px-5 py-10">
<p class="text-2xl font-normal my-2 mb-10 uppercase text-gray-500">
Certificate<br />
of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class="student-name text-center text-9xl">Rotimi Best</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">
has successfully completed introductory courses in
</p>
<div>
<p
class="bg-transparent font-semibold text-blue-800 text-center uppercase my-10 text-3xl"
>
Figma for Beginners
</p>
</div>
</div>
<p class="text-xl font-light mb-4 text-justify">
Netlify Functions can stream data to clients as it becomes available, rather than
returning a buffered payload at the end of the computation. This lets developers and
frameworks create faster experiences by using streaming and partial hydration to get
content and interactions in front of people as quickly as possible. For an extended
example that demonstrates a more likely response streaming use case, consider this
function
</p>
<div class="flex justify-between w-[70%] mx-auto text-center my-14">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<div class="flex items-center gap-1 mt-14">
<img
src="https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/user/Coding%20Dojo1684164720355.webp"
alt="logo"
class="w-24 h-24 rounded-md"
/>
<p class="font-semibold capitalize text-5xl">Coding Dojo</p>
</div>
</div>
</div>
</div> -->
<!-- <div
class="flex flex-col items-center justify-center border-4 border-pink-700 py-3 w-full h-full"
>
<div class="flex items-center gap-1 my-2 mb-10">
<img
src="https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/user/Coding%20Dojo1684164720355.webp"
alt="logo"
class="w-24 h-24 rounded-md"
/>
<p class="font-semibold capitalize text-5xl">Coding Dojo</p>
</div>
<div class="w-full px-5 py-3">
<p
class="text-xl text-center font-normal my-2 uppercase tracking-widest text-gray-500 w-full"
>
Certificate of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class="student-name text-center text-8xl">Rotimi Best</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">has successfully completed a courses in</p>
<div class="my-10">
<p class="bg-transparent font-semibold text-pink-700 text-center uppercase text-3xl">
Figma for Beginners
</p>
</div>
</div>
<p class="text-xl text-center text-gray-500 font-light">
Netlify Functions can stream data to clients as it becomes available, rather than
returning a buffered payload at the end of the computation. This lets developers and
frameworks create faster experiences by using streaming and partial hydration to get
content and interactions in front of people as quickly as possible. For an extended
example that demonstrates a more likely response streaming use case, consider this
function
</p>
<div class="flex justify-between w-[70%] mx-auto text-center my-14">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
</div> -->
</body>
</html>
-182
View File
@@ -1,182 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
border-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.hero {
background-color: rgb(0, 48, 255);
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 3rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
h4 {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
.footer {
font-size: 3rem;
scale: 3;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
<header class="flex justify-center items-center flex-col border-[50px] mx-10 h-[100vh] m-0">
<h1 class="font-bold text-8xl text-black m-0 mx-96">React js for Beginners</h1>
<h4 class="text-md text-2xl font-light text-black mt-5 ">By TheSpace</h4>
</header>
<div class="root mx-auto mt-5 prose">
<div class="hero w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
32
</h3>
<h1 class="font-bold text-5xl text-white m-0">The Yams</h1>
<h4 class="text-md font-normal text-white m-0">By The Space</h4>
</div>
<main>
<section>
<h1>Lesson Note: Introduction and Counselling</h1>
<h2>Course: The Ultimate Guide to relocating to the UK via Study Path</h2>
<h3>Lesson Overview:</h3>
<p>This lesson serves as the introduction to the course &quot;The Ultimate Guide to relocating to the UK via
Study Path.&quot; In this session, we will provide an overview of the course and discuss the importance of
effective counseling in the relocation process.</p>
<h3>Learning Objectives:</h3>
<p>By the end of this lesson, students should be able to:</p>
<ul>
<li>Understand the goals and structure of the course.</li>
<li>Recognize the significance of counseling in the relocation process.</li>
<li>Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.</li>
</ul>
<h3>Lesson Content:</h3>
<ol>
<li>
<p><strong>Introduction to the Course</strong></p>
<ul>
<li>Briefly introduce the course title and explain its relevance to students&#39; interests.</li>
<li>Highlight the main topics and modules covered throughout the course.</li>
</ul>
</li>
<li>
<p><strong>Importance of Counseling</strong></p>
<ul>
<li>Define the role of counseling in the relocation process.</li>
<li>Discuss how counseling can assist students in making informed decisions regarding their studies and
life in the UK.</li>
<li>Explain the benefits of seeking professional guidance during the relocation journey.</li>
</ul>
</li>
<li>
<p><strong>Steps for Relocating to the UK via Study Path</strong></p>
<ul>
<li>Provide an overview of the necessary steps involved in relocating to the UK for study purposes.</li>
<li>Discuss the importance of thorough planning, research, and documentation in the relocation process.
</li>
<li>Highlight key points such as visa applications, university admissions, accommodation arrangements, and
financial considerations.</li>
</ul>
</li>
<li>
<p><strong>Q&amp;A and Discussion Time</strong></p>
<ul>
<li>Allocate time for students to ask questions, provide comments, or share personal experiences.</li>
<li>Encourage active participation and foster a supportive learning environment.</li>
</ul>
</li>
</ol>
<h3>Conclusion:</h3>
<p>In this lesson, we introduced the course &quot;The Ultimate Guide to relocating to the UK via Study
Path&quot; and emphasized the significance of counseling in the relocation process. We explored the essential
steps involved in relocating to the UK and highlighted the importance of thorough planning and research.
Students should now have a better understanding of the course objectives and overall expectations. In the next
session, we will delve deeper into the specific requirements for studying in the UK and provide detailed
guidance on university admissions.</p>
</section>
<section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
class="underline">Open Here</a>
</div>
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
</ol>
</div>
</section>
</main>
</div>
</body>
</html>
-18
View File
@@ -1,18 +0,0 @@
# fly.toml app configuration file generated for classroomio-server on 2023-07-27T21:01:09+01:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = "classroomio-server"
primary_region = "lhr"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 3002
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
-186
View File
@@ -1,186 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
background-image: url("https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/course/course-pdf-background.jpg");
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.hero {
background-color: rgb(0, 48, 255);
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 3rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
h4 {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
.footer {
font-size: 3rem;
scale: 3;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
<header class="border-l-[50px] mb-[15%] h-[100vh] m-0 relative">
<h1 class="font-bold text-8xl text-black m-0 w-3/4 absolute left-5 top-40">React js for Beginners</h1>
<h4 class="text-md text-2xl font-light text-black m-0 absolute left-10 bottom-24">By TheSpace</h4>
</header>
<div class="root mx-auto mt-5 prose">
<div class="hero w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
32
</h3>
<h1 class="font-bold text-5xl text-white m-0">The Yams</h1>
<h4 class="text-md font-normal text-white m-0">By The Space</h4>
</div>
<main>
<section>
<h1>Lesson Note: Introduction and Counselling</h1>
<h2>Course: The Ultimate Guide to relocating to the UK via Study Path</h2>
<h3>Lesson Overview:</h3>
<p>This lesson serves as the introduction to the course &quot;The Ultimate Guide to relocating to the UK via
Study Path.&quot; In this session, we will provide an overview of the course and discuss the importance of
effective counseling in the relocation process.</p>
<h3>Learning Objectives:</h3>
<p>By the end of this lesson, students should be able to:</p>
<ul>
<li>Understand the goals and structure of the course.</li>
<li>Recognize the significance of counseling in the relocation process.</li>
<li>Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.</li>
</ul>
<h3>Lesson Content:</h3>
<ol>
<li>
<p><strong>Introduction to the Course</strong></p>
<ul>
<li>Briefly introduce the course title and explain its relevance to students&#39; interests.</li>
<li>Highlight the main topics and modules covered throughout the course.</li>
</ul>
</li>
<li>
<p><strong>Importance of Counseling</strong></p>
<ul>
<li>Define the role of counseling in the relocation process.</li>
<li>Discuss how counseling can assist students in making informed decisions regarding their studies and
life in the UK.</li>
<li>Explain the benefits of seeking professional guidance during the relocation journey.</li>
</ul>
</li>
<li>
<p><strong>Steps for Relocating to the UK via Study Path</strong></p>
<ul>
<li>Provide an overview of the necessary steps involved in relocating to the UK for study purposes.</li>
<li>Discuss the importance of thorough planning, research, and documentation in the relocation process.
</li>
<li>Highlight key points such as visa applications, university admissions, accommodation arrangements, and
financial considerations.</li>
</ul>
</li>
<li>
<p><strong>Q&amp;A and Discussion Time</strong></p>
<ul>
<li>Allocate time for students to ask questions, provide comments, or share personal experiences.</li>
<li>Encourage active participation and foster a supportive learning environment.</li>
</ul>
</li>
</ol>
<h3>Conclusion:</h3>
<p>In this lesson, we introduced the course &quot;The Ultimate Guide to relocating to the UK via Study
Path&quot; and emphasized the significance of counseling in the relocation process. We explored the essential
steps involved in relocating to the UK and highlighted the importance of thorough planning and research.
Students should now have a better understanding of the course objectives and overall expectations. In the next
session, we will delve deeper into the specific requirements for studying in the UK and provide detailed
guidance on university admissions.</p>
</section>
<section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
class="underline">Open Here</a>
</div>
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
</ol>
</div>
</section>
</main>
</div>
</body>
</html>
-20
View File
@@ -1,20 +0,0 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
PUBLIC_SUPABASE_URL: z.string(),
PUBLIC_SUPABASE_ANON_KEY: z.string(),
ZOHO_TOKEN: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_SENDER: z.string().optional()
});
export const env = envSchema.parse(process.env);
-25
View File
@@ -1,25 +0,0 @@
import { env } from '$src/config/env';
const {
CLOUDFLARE_BUCKET_DOMAIN,
CLOUDFLARE_ACCESS_KEY,
CLOUDFLARE_SECRET_ACCESS_KEY,
CLOUDFLARE_ACCOUNT_ID
} = env;
export const CLOUDFLARE = {
CONFIGS: {
BUCKET_DOMAIN: CLOUDFLARE_BUCKET_DOMAIN!,
ACCESS_KEY: CLOUDFLARE_ACCESS_KEY!,
SECRET_ACCESS_KEY: CLOUDFLARE_SECRET_ACCESS_KEY!,
ACCOUNT_ID: CLOUDFLARE_ACCOUNT_ID!
},
R2: {
BUCKET: 'videos',
BUCKET_DOMAIN: CLOUDFLARE_ACCOUNT_ID
? `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
: CLOUDFLARE_BUCKET_DOMAIN!,
PRESIGN_EXPIRATION_TIME: 60 * 60, // 1 hour
DOWNLOAD_EXPIRATION_TIME: 60 * 60 * 3 // 3 hours
}
} as const;
-44
View File
@@ -1,44 +0,0 @@
import { Context, Next } from 'hono';
import { validateUser } from '$src/utils/auth/validate-user';
export const authMiddleware = async (c: Context, next: Next) => {
try {
const authHeader = c.req.header('authorization');
if (!authHeader) {
return c.json(
{
success: false,
message: 'No authorization header provided'
},
401
);
}
// Extract the token from "Bearer <token>"
const token = authHeader.split(' ')[1];
if (!token) {
return c.json(
{
success: false,
message: 'Invalid authorization header format'
},
401
);
}
const user = await validateUser(token);
c.set('user', user); // Attach the user to the context
await next();
} catch (error) {
return c.json(
{
success: false,
message: 'Unauthorized'
},
401
);
}
};
-41
View File
@@ -1,41 +0,0 @@
import { Context, Hono } from 'hono';
import katex from 'katex';
import { z } from 'zod';
export const katexRouter = new Hono();
katexRouter.get('/', async (c: Context) => {
try {
const original = c.req.raw.url;
const latexString = original.split('?')[1].replaceAll('&plus;', '+').replaceAll('&space;', '');
const html = katex.renderToString(latexString, {
output: 'mathml',
throwOnError: false
});
return c.html(html);
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.errors
},
400
);
}
console.error('Error rendering LaTeX:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
500
);
}
});
-70
View File
@@ -1,70 +0,0 @@
import { Context, Hono } from 'hono';
import { sendWithNodemailer, sendWithZoho } from '$src/services/mail';
import { ZSendEmailValidation } from '$src/types/mail';
import { env } from '$src/config/env';
import { z } from 'zod';
export const mailRouter = new Hono();
mailRouter.post('/send', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZSendEmailValidation.parse(data);
const results = await Promise.all(
validatedData.map(async (emailData) => {
try {
const res = env.ZOHO_TOKEN
? await sendWithZoho(emailData)
: await sendWithNodemailer(emailData);
console.log('Email status:', res);
return res;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
})
);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
details: results
},
500
);
}
return c.json({ success: true, details: results });
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.errors
},
400
);
}
console.error('Error sending emails:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
500
);
}
});
-97
View File
@@ -1,97 +0,0 @@
import { extractNameAndEmail, withEmailTemplate } from '$src/utils/mail';
import { nodemailerTransporter, zohoClient } from '$src/utils/email';
import { Hono } from 'hono';
import type { TSendEmailValidation } from '$src/types/mail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
export const mailRouter = new Hono();
interface EmailResponse {
success: boolean;
error?: string;
details?: unknown;
}
let transporter: Transporter | null;
nodemailerTransporter().then((t: Transporter | null) => {
transporter = t;
});
export async function sendWithNodemailer(
emailData: TSendEmailValidation[0]
): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
if (!transporter) {
return {
success: false,
error: 'Email transporter not initialized'
};
}
try {
const result = await transporter.sendMail({
from: from || env.SMTP_SENDER,
to,
subject,
replyTo,
html: withEmailTemplate(content)
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise<EmailResponse> {
const { from, to, subject, content } = emailData;
const fromData = extractNameAndEmail(from || '');
if (!fromData) {
return {
success: false,
error: 'Invalid from data'
};
}
try {
const result = await zohoClient.sendMail({
from: {
address: fromData.email,
name: fromData.name
},
to: [
{
email_address: {
address: to
}
}
],
subject,
htmlbody: content
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
-39
View File
@@ -1,39 +0,0 @@
import { ALLOWED_CONTENT_TYPES, MAX_FILE_SIZE } from '$src/constants/upload';
import { z } from 'zod';
export * from './lesson';
export const ZCourseDownloadContent = z.object({
courseTitle: z.string(),
orgName: z.string(),
orgTheme: z.string(),
lessons: z.array(
z.object({
lessonTitle: z.string(),
lessonNumber: z.string(),
lessonNote: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional()
})
)
});
export type TCourseDownloadContent = z.infer<typeof ZCourseDownloadContent>;
export const ZCoursePresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_CONTENT_TYPES, {
errorMap: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`
})
})
});
export type TCoursePresignUrlUpload = z.infer<typeof ZCoursePresignUrlUpload>;
export const ZCourseDownloadPresignedUrl = z.object({
keys: z.array(z.string()).min(1, 'File names array is required')
});
export type TCourseDownloadPresignedUrl = z.infer<typeof ZCourseDownloadPresignedUrl>;
-25
View File
@@ -1,25 +0,0 @@
import { z } from 'zod';
export const ZLessonDownloadContent = z.object({
title: z.string(),
number: z.string(),
orgName: z.string(),
note: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional(),
courseTitle: z.string()
});
export type TLessonDownloadContent = z.infer<typeof ZLessonDownloadContent>;
export const ZCertificateDownload = z.object({
theme: z.string().min(1),
studentName: z.string().min(1),
courseName: z.string().min(1),
courseDescription: z.string().min(1),
orgName: z.string().min(1),
orgLogoUrl: z.string().url().optional(),
facilitator: z.string().optional()
});
export type TCertificateDownload = z.infer<typeof ZCertificateDownload>;
-13
View File
@@ -1,13 +0,0 @@
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>;
@@ -1,21 +0,0 @@
import { User } from '@supabase/supabase-js';
import { getSupabase } from '../supabase.js';
const supabase = getSupabase();
export async function validateUser(accessToken: string): Promise<User> {
let user: User | null = null;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
throw new Error('Unauthenticated user');
}
return user;
}
-124
View File
@@ -1,124 +0,0 @@
import type { TCertificateDownload } from '$src/types/course/lesson';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
export interface CertificateData {
theme: string;
studentName: string;
courseName: string;
courseDescription: string;
orgLogoUrl: string;
orgName: string;
facilitator?: string;
}
export interface CertificateGenerationResult {
success: boolean;
buffer?: Buffer;
error?: string;
}
export const generateCertificate = async (data: TCertificateDownload) => {
const { html, styles } = generateCertificateHtml(data);
return getCloudflarePdfBuffer(html, styles);
};
function generateCertificateHtml(data: TCertificateDownload): { html: string; styles: string } {
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
const html = `
<html>
<head>
<meta charset="utf-8">
<title>Certificate of Completion</title>
</head>
<body>
<div class="certificate">
<div class="header">
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
<h1 class="title">Certificate of Completion</h1>
</div>
<div class="content">
<h2 class="student-name">${studentName}</h2>
<p class="course-name">${courseName}</p>
<p class="description">${courseDescription}</p>
</div>
<div class="footer">
<div class="signature">
<p>${facilitator || 'Course Facilitator'}</p>
<p>${orgName}</p>
</div>
<p class="date">${new Date().toLocaleDateString()}</p>
</div>
</div>
</body>
</html>
`;
const styles = `
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.certificate {
width: 1123px;
height: 794px;
margin: 0 auto;
background-color: white;
position: relative;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding: 20px;
}
.logo {
max-width: 200px;
margin-bottom: 20px;
}
.title {
font-size: 36px;
color: #333;
margin-bottom: 10px;
}
.content {
text-align: center;
padding: 40px;
}
.student-name {
font-size: 48px;
color: #2c3e50;
margin-bottom: 20px;
}
.course-name {
font-size: 24px;
color: #34495e;
margin-bottom: 10px;
}
.description {
font-size: 18px;
color: #7f8c8d;
margin-bottom: 30px;
}
.footer {
position: absolute;
bottom: 40px;
width: 100%;
text-align: center;
}
.signature {
margin-top: 20px;
}
.date {
margin-top: 10px;
color: #95a5a6;
}
`;
const pdf = {
html: html,
styles: styles
};
return pdf;
}
-27
View File
@@ -1,27 +0,0 @@
import { CLOUDFLARE } from '$src/constants';
export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
console.log('Generating PDF with Cloudflare API...');
try {
const pdfResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE.CONFIGS.ACCOUNT_ID}/browser-rendering/pdf`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.ACCESS_KEY}`
},
body: JSON.stringify({
html: html,
addStyleTag: [{ content: `${styles}` }]
})
}
);
console.log('PDF response status:', pdfResponse.status);
return pdfResponse;
} catch (error) {
console.error('Error generating PDF:', error);
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
}
};
-170
View File
@@ -1,170 +0,0 @@
import type { TCourseDownloadContent } from '$src/types/course';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
body {
font-family: 'Roboto', sans-serif;
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 1.5rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
.roboto-mono {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full break-after-page">
${body}
</body>
</html>
`;
}
type TLessonBody = TCourseDownloadContent['lessons'][0];
function getLessonBody(
{ lessonTitle, lessonNumber, lessonNote, slideUrl, video }: TLessonBody,
orgName: string,
orgTheme: string
): string {
const noteHtml = marked.parse(lessonNote);
const showExtraResources = slideUrl || (video && video.length > 0);
return `
<div
class="bg-[${orgTheme ? orgTheme : '#0030FF'}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-[${orgTheme ? orgTheme : '#0030FF'}] bg-white text-md font-bold tracking-tighter">
${lessonNumber}
</h3>
<h1 class="text-white m-0">${lessonTitle}</h1>
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
</div>
<main class="break-after-page">
<section>
${noteHtml}
</section>
<section>
${
showExtraResources
? `
<section>
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
${
slideUrl
? `<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
</div>`
: ''
}
${
video && video.length > 0
? `
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
${video
.map(
(videoData, index) => `
<li key=${
index + 1
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
`
)
.join('')}
</ol>
</div>
`
: ''
}
</section>
`
: ''
}
</section>
</main>
`;
}
function getCourseBody({
courseTitle,
orgName,
orgTheme,
lessons
}: TCourseDownloadContent): string {
return `
<header class="flex justify-center items-center flex-col border-[${orgTheme ? orgTheme : '#0030FF'}] border-[40px] mx-3 h-[100vh] m-0">
<h1 class="font-bold text-8xl text-black m-0 mx-96">${courseTitle}</h1>
<h4 class="text-md text-2xl font-light text-black mt-5 ">${orgName}</h4>
</header>
<div class="root mx-auto mt-5 prose break-after-page">
${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme)).join('')}
</div>
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string,
orgTheme: string
): Promise<Buffer> {
throw 'Download disabled';
}
export async function generateCoursePdf(params: TCourseDownloadContent): Promise<Buffer> {
const body = getCourseBody(params);
const html = getHtmlTemplate(body);
console.log(html);
return await generateSinglePdfFromHtml(html, params.courseTitle, params.orgTheme);
}
-35
View File
@@ -1,35 +0,0 @@
import { SendMailClient } from 'zeptomail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
import nodemailer from 'nodemailer';
export const zohoClient = new SendMailClient({
url: 'api.zeptomail.eu/',
token: env.ZOHO_TOKEN!
});
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
console.error('SMTP configuration missing');
return null;
}
try {
const transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT || '465', 10),
secure: true,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD
}
});
await transporter.verify();
return transporter;
} catch (error) {
console.error('Transporter error:', error);
return null;
}
};
-3
View File
@@ -1,3 +0,0 @@
export default function genUniqueId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
-161
View File
@@ -1,161 +0,0 @@
import type { TLessonDownloadContent } from '$src/types/course/lesson';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
background-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 1.5rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
.roboto-mono {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
${body}
</body>
</html>
`;
}
function getLessonBody({
title,
number,
orgName,
note,
slideUrl,
video,
courseTitle
}: TLessonDownloadContent): string {
const noteHtml = marked.parse(note);
const showExtraResources = slideUrl || (video && video.length > 0);
return `
<div class="root mx-auto mt-5 prose">
<header
class="w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
${number}
</h3>
<h1 class="text-white m-0">${title}</h1>
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
</header>
<main>
<section>
${noteHtml}
</section>
<section>
${
showExtraResources
? `
<section>
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
${
slideUrl
? `<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
</div>`
: ''
}
${
video && video.length > 0
? `
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
${video
.map(
(videoData, index) => `
<li key=${
index + 1
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
`
)
.join('')}
</ol>
</div>
`
: ''
}
</section>
`
: ''
}
</section>
</main>
</div>
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string
): Promise<Buffer> {
throw 'Download disabled';
}
export async function generateLessonPdf(params: TLessonDownloadContent): Promise<Buffer> {
const body = getLessonBody(params);
const html = getHtmlTemplate(body);
console.log(html);
return await generateSinglePdfFromHtml(html, params.courseTitle);
}
-213
View File
@@ -1,213 +0,0 @@
interface FromData {
name: string;
email: string;
}
// format: "ClassroomIO Developers (via ClassroomIO.com)" <[email protected]>
export function extractNameAndEmail(str: string): FromData | undefined {
// Use regular expressions to match the name and email
const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/;
const match = str.match(regex);
if (match) {
// Extract the name and email from the match groups
const name = match[1];
const email = match[2] + '@' + match[3];
return { name, email };
} else {
// Return undefined if the format doesn't match
return { name: str, email: str };
}
}
export const withEmailTemplate = (content: string): string =>
`<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<base target="_blank" />
<style>
body {
background-color: #F6F9FF;
font-family: "Poppins", "Helvetica Neue", "Segoe UI", Helvetica,
sans-serif;
font-size: 15px;
font-weight: 400;
line-height: 26px;
margin: 0;
}
pre {
background: #f4f4f4;
padding: 2px;
}
hr {
border-top: 1px dashed #94a3b8;
margin-top: 1rem;
margin-bottom: 1rem;
}
table {
width: 100%;
}
table td {
padding: 5px 10px 0px;
vertical-align: middle;
}
.socialicons {
background-color: #fff;
border-radius: 5px;
padding: 5px;
width: initial;
margin: 0 auto;
max-width: 200px;
}
.brandcolor {
color: #00c4b8;
}
.tooltip {
background-color: #f1f5f9;
padding: 1rem;
border-radius: 1rem;
color: #475569;
margin-top: 15px;
margin-bottom: 15px;
}
.tooltip a {
color: #1e293b;
}
.button {
margin-top: 12px;
background: #0233BD;
border-radius: 8px;
text-decoration: none !important;
color: #fff !important;
font-weight: 500;
padding: 10px 30px;
display: inline-block;
font-size: 0.9em;
}
.button:hover {
background: #0233BD;
}
.footer {
text-align: center;
font-size: 12px;
color: #cbd5e1;
}
.footer a {
color: #cbd5e1;
}
.gutter {
padding: 5px 30px;
text-align: center;
background-color: #fff;
border-bottom: 1px solid rgb(221, 208, 208);
max-width: 525px;
margin: 0 auto;
}
img {
max-width: 100%;
height: auto;
}
.gutter img {
max-width: 280px;
}
h1,
h2,
h3,
h4 {
font-weight: 600;
}
@media screen and (max-width: 600px) {
.wrap {
max-width: auto;
}
.gutter {
padding: 10px;
}
}
</style>
</head>
<body style="
background-color: #F6F9FF;
font-family: 'Poppins', 'Helvetica Neue', 'Segoe UI', Helvetica,
sans-serif;
font-size: 15px;
line-height: 26px;
padding: 20px 0;
margin: 0;
color: #1e293b;
">
<div class="gutter">
<a href="https://classroomio.com" target="_blank">
<img src="https://brand.cdn.clsrio.com/cio-bg-transparent.png" alt="ClassroomIO Logo" /></a>
</div>
<div class="wrap" style="
background-color: #fff;
padding: 40px 30px;
max-width: 525px;
margin: 0 auto;
">
${content}
</div>
<div class="footer" style="
text-align: center;
font-size: 12px;
color: #fff;
padding: 30px;
background-color: #0542CC;
max-width: 525px;
margin: 0 auto;">
<p>Find us on</p>
<table class="socialicons">
<tr>
<td>
<a target="_blank" href="https://twitter.com/classroomio"><img title="X"
src="https://brand.cdn.clsrio.com/socials/twitter.png" alt="X" width="19" /></a>
</td>
<td>
<a target="_blank" href="https://www.youtube.com/@ClassroomIO"><img title="Youtube"
src="https://brand.cdn.clsrio.com/socials/youtube.png" alt="Youtube" width="24" /></a>
</td>
<td>
<a target="_blank" href="https://www.facebook.com/classroomiohq"><img title="Facebook"
src="https://brand.cdn.clsrio.com/socials/facebook.png" alt="Facebook" width="24" /></a>
</td>
</tr>
</table>
<p style="padding-top: 8px; line-height: initial">
ClassroomIO ${new Date().getFullYear()}. All rights reserved.<br />
<a style="text-decoration: none" href="https://app.enzuzo.com/policies/tos/958fc978-5477-11ee-a03b-7b111830c594"
target="_blank">Terms</a>
|
<a style="text-decoration: none"
href="https://app.enzuzo.com/policies/privacy/958fc978-5477-11ee-a03b-7b111830c594" target="_blank">Privacy
Policy</a>
</p>
</div>
</body>
</html>
`;
-56
View File
@@ -1,56 +0,0 @@
import {
DeleteObjectCommand,
DeleteObjectCommandInput,
GetObjectCommand,
GetObjectCommandInput,
PutObjectCommand,
PutObjectCommandInput,
S3Client
} from '@aws-sdk/client-s3';
import { CLOUDFLARE } from '$src/constants';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
export const s3Client = new S3Client({
region: 'auto',
endpoint: `https://${CLOUDFLARE.CONFIGS.ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: CLOUDFLARE.CONFIGS.ACCESS_KEY,
secretAccessKey: CLOUDFLARE.CONFIGS.SECRET_ACCESS_KEY
}
});
export async function uploadToS3(
params: PutObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new PutObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function getFromS3(
params: GetObjectCommandInput
): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const data = await s3Client.send(new GetObjectCommand(params));
return { success: true, data };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function deleteFromS3(
params: DeleteObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new DeleteObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
-36
View File
@@ -1,36 +0,0 @@
import { SupabaseClient, createClient } from '@supabase/supabase-js';
import { env } from '$src/config/env';
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = env;
let supabase: SupabaseClient | null = null;
interface SupabaseConfig {
supabaseConfig: {
url: string;
anonKey: string;
};
}
export function getSupabase(config?: SupabaseConfig): SupabaseClient {
if (supabase) return supabase;
const finalConfig = config ?? {
supabaseConfig: {
url: PUBLIC_SUPABASE_URL,
anonKey: PUBLIC_SUPABASE_ANON_KEY
}
};
try {
supabase = createClient(finalConfig.supabaseConfig.url, finalConfig.supabaseConfig.anonKey, {
auth: { persistSession: false }
});
} catch (error) {
console.error('Error starting supabase', error);
throw error;
}
return supabase;
}
-24
View File
@@ -1,24 +0,0 @@
import { nanoid } from 'nanoid';
import path from 'path';
/**
* Safely extracts the file extension from a filename.
* Returns an empty string if no extension is found.
*/
export function getExtension(filename: string): string {
if (!filename || typeof filename !== 'string') return '';
const ext = path.extname(filename).split('.');
return ext.length > 1 ? ext[ext.length - 1] : '';
}
/**
* Generates a unique file key for storage, preserving the file extension.
* Throws an error if the filename is invalid or has no extension.
*/
export function generateFileKey(fileName: string): string {
const ext = getExtension(fileName);
if (!ext) {
throw new Error('Invalid file name or missing file extension');
}
return `${nanoid()}.${ext}`;
}
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/']
}
}
});
-81
View File
@@ -1,81 +0,0 @@
# ----------- Build Stage -----------
FROM node:18.18.0-alpine AS builder
# Add build arguments
ARG PUBLIC_SUPABASE_URL
ARG PUBLIC_SUPABASE_ANON_KEY
ARG PRIVATE_SUPABASE_SERVICE_ROLE
ARG PRIVATE_APP_HOST
ARG PRIVATE_APP_SUBDOMAINS
ARG UNSPLASH_API_KEY
ARG OPENAI_API_KEY
ARG PUBLIC_SERVER_URL
ARG PUBLIC_IP_REGISTRY_KEY
WORKDIR /cio
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy monorepo files
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY apps/dashboard/package.json apps/dashboard/
COPY packages/tsconfig ./packages/tsconfig
COPY packages/shared ./packages/shared
# Install all dependencies (monorepo context)
RUN pnpm install
# Copy the rest of the dashboard app
COPY apps/dashboard ./apps/dashboard
# Set env vars for SvelteKit to use Node adapter
ENV PUBLIC_IS_SELFHOSTED=true \
PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
PRIVATE_SUPABASE_SERVICE_ROLE=$PRIVATE_SUPABASE_SERVICE_ROLE \
PRIVATE_APP_HOST=$PRIVATE_APP_HOST \
PRIVATE_APP_SUBDOMAINS=$PRIVATE_APP_SUBDOMAINS \
UNSPLASH_API_KEY=$UNSPLASH_API_KEY \
OPENAI_API_KEY=$OPENAI_API_KEY \
PUBLIC_SERVER_URL=$PUBLIC_SERVER_URL \
PUBLIC_IP_REGISTRY_KEY=$PUBLIC_IP_REGISTRY_KEY
# Build the dashboard app
WORKDIR /cio/apps/dashboard
RUN pnpm install
RUN NODE_OPTIONS=--max-old-space-size=4096 pnpm build
# ----------- Production Stage -----------
FROM node:18.18.0-alpine AS runner
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy only production node_modules for dashboard
COPY --from=builder ./cio/node_modules ./node_modules
COPY --from=builder ./cio/package.json ./package.json
COPY --from=builder ./cio/apps/dashboard/node_modules ./apps/dashboard/node_modules
COPY --from=builder ./cio/apps/dashboard/package.json ./apps/dashboard/package.json
COPY --from=builder ./cio/apps/dashboard/build ./apps/dashboard/build
# Expose the port (default for SvelteKit Node adapter)
EXPOSE 3000
# Set environment variables for production
ENV NODE_ENV=production \
PUBLIC_IS_SELFHOSTED=true \
PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
PRIVATE_SUPABASE_SERVICE_ROLE=$PRIVATE_SUPABASE_SERVICE_ROLE \
PRIVATE_APP_HOST=$PRIVATE_APP_HOST \
PRIVATE_APP_SUBDOMAINS=$PRIVATE_APP_SUBDOMAINS \
UNSPLASH_API_KEY=$UNSPLASH_API_KEY \
OPENAI_API_KEY=$OPENAI_API_KEY \
PUBLIC_SERVER_URL=$PUBLIC_SERVER_URL \
PUBLIC_IP_REGISTRY_KEY=$PUBLIC_IP_REGISTRY_KEY
# Start the app
CMD ["pnpm", "dashboard:start"]
+1 -1
View File
@@ -1,8 +1,8 @@
import { getSupabase, supabase } from '$lib/utils/functions/supabase'; import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import type { CurrentOrg } from '$lib/utils/types/org'; import type { CurrentOrg } from '$lib/utils/types/org';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import type { MetaTagsProps } from 'svelte-meta-tags'; import type { MetaTagsProps } from 'svelte-meta-tags';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { blockedSubdomain } from '$lib/utils/constants/app'; import { blockedSubdomain } from '$lib/utils/constants/app';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
+4 -4
View File
@@ -140,8 +140,8 @@ docker compose up --build
The services will be available at: The services will be available at:
- Dashboard: http://localhost:3000 - Dashboard: http://localhost:3082
- Backend: http://localhost:3002 - Backend: http://localhost:3081
### Service Configuration ### Service Configuration
@@ -149,7 +149,7 @@ The docker-compose file sets up two services:
#### Backend Service #### Backend Service
- Runs on port 3002 - Runs on port 3081
- Uses Node.js slim image - Uses Node.js slim image
- Mounts the api code directory - Mounts the api code directory
- Requires environment variables for: - Requires environment variables for:
@@ -159,7 +159,7 @@ The docker-compose file sets up two services:
#### Dashboard Service #### Dashboard Service
- Runs on port 3000 - Runs on port 3082
- Uses Node.js Alpine image - Uses Node.js Alpine image
- Uses pnpm for package management - Uses pnpm for package management
- Requires environment variables for: - Requires environment variables for:
+4
View File
@@ -0,0 +1,4 @@
# This .dockerignore is a symlink reference
# The actual .dockerignore is in the root directory
# Docker will use the root .dockerignore when building from the docker/ directory
+31
View File
@@ -0,0 +1,31 @@
FROM node:20.19.3-slim AS app
RUN mkdir /app
WORKDIR /app
# Install pnpm, turbo and healthcheck tools
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN npm install -g [email protected]
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY pnpm-workspace.yaml ./
COPY turbo.json ./
COPY packages/tsconfig ./packages/tsconfig
COPY packages/shared ./packages/shared
# Copy the api package.json
COPY apps/api/package.json ./apps/api/package.json
RUN pnpm install
# Copy app source
COPY . .
RUN pnpm build --filter @cio/api
EXPOSE 3081
RUN ls -la
CMD ["pnpm", "api:start"]
+60
View File
@@ -0,0 +1,60 @@
# ----------- Build Stage -----------
FROM node:20.19.3-alpine AS builder
WORKDIR /cio
# Install pnpm globally so it's available in the final container
RUN npm install -g pnpm
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy everything (filtered by .dockerignore)
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY pnpm-workspace.yaml ./
COPY turbo.json ./
COPY packages/tsconfig ./packages/tsconfig
COPY packages/shared ./packages/shared
# Copy the api package.json
COPY apps/dashboard ./apps/dashboard
COPY apps/api ./apps/api
# Install all dependencies (monorepo context)
RUN pnpm install
RUN ls -la
# Build the dashboard app
RUN NODE_OPTIONS="--max-old-space-size=3072" PUBLIC_IS_SELFHOSTED=true pnpm build --filter @cio/dashboard
# ----------- Production Stage -----------
FROM node:20.19.3-alpine AS runner
WORKDIR /app
# Install pnpm and turbo before switching to non-root user
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN npm install -g [email protected]
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 cio-user
USER cio-user
# Copy the entire dashboard app (including build output)
COPY --from=builder --chown=cio-user:nodejs ./cio/apps/dashboard ./apps/dashboard
COPY --from=builder --chown=cio-user:nodejs ./cio/node_modules ./node_modules
COPY --from=builder --chown=cio-user:nodejs ./cio/package.json ./package.json
COPY --from=builder --chown=cio-user:nodejs ./cio/turbo.json ./turbo.json
COPY --from=builder --chown=cio-user:nodejs ./cio/pnpm-workspace.yaml ./pnpm-workspace.yaml
# Expose the port (default for SvelteKit Node adapter)
EXPOSE 3082
# Set environment variables for production
ENV NODE_ENV=production \
PUBLIC_IS_SELFHOSTED=true
# Start the app
CMD ["pnpm", "dashboard:start"]
@@ -8,28 +8,9 @@ version: "3.8"
services: services:
api: api:
build: image: classroomio/api:latest
context: ./apps/api
dockerfile: Dockerfile
args:
- 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}
ports: ports:
- "3002:3002" - "3081:3081"
volumes:
# Node modules (optional - for faster development)
- ./apps/api/node_modules:/app/node_modules
# Build output directory (if you want to access built files)
- ./apps/api/dist:/app/dist
environment: environment:
- NODE_ENV=production - NODE_ENV=production
# Supabase - Database # Supabase - Database
@@ -46,27 +27,13 @@ services:
- SMTP_PASSWORD=${SMTP_PASSWORD} - SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_PORT=${SMTP_PORT} - SMTP_PORT=${SMTP_PORT}
- SMTP_SENDER=${SMTP_SENDER} - SMTP_SENDER=${SMTP_SENDER}
# Redis
- REDIS_URL=${REDIS_URL}
dashboard: dashboard:
build: image: classroomio/dashboard:latest
context: .
dockerfile: ./apps/dashboard/Dockerfile
args:
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
- PRIVATE_SUPABASE_SERVICE_ROLE=${PRIVATE_SUPABASE_SERVICE_ROLE}
- PRIVATE_APP_HOST=${PRIVATE_APP_HOST}
- PRIVATE_APP_SUBDOMAINS=${PRIVATE_APP_SUBDOMAINS}
- UNSPLASH_API_KEY=${UNSPLASH_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
- PUBLIC_IP_REGISTRY_KEY=${PUBLIC_IP_REGISTRY_KEY}
ports: ports:
- "3000:3000" - "3082:3082"
volumes:
- .:/app
- /app/node_modules
- /app/apps/dashboard/node_modules
environment: environment:
- PUBLIC_IS_SELFHOSTED=true - PUBLIC_IS_SELFHOSTED=true
# Supabase - Database # Supabase - Database
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# Docker Hub Push Script for ClassroomIO
# This script builds and pushes the API and Dashboard images to Docker Hub
set -e # Exit on error
# Configuration
DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME:-classroomio}" # Change this to your Docker Hub username
VERSION="${VERSION:-latest}" # Can be overridden with VERSION env var
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${BLUE}================================${NC}"
echo -e "${BLUE}ClassroomIO Docker Hub Publisher${NC}"
echo -e "${BLUE}================================${NC}"
echo ""
# Build and push API
echo -e "${GREEN}Building API image...${NC}"
docker build \
-f docker/Dockerfile.api \
-t ${DOCKERHUB_USERNAME}/api:${VERSION} \
-t ${DOCKERHUB_USERNAME}/api:latest \
.
echo -e "${GREEN}Pushing API image to Docker Hub...${NC}"
docker push ${DOCKERHUB_USERNAME}/api:${VERSION}
docker push ${DOCKERHUB_USERNAME}/api:latest
echo -e "${GREEN}✓ API image published successfully!${NC}"
echo ""
# Build and push Dashboard
echo -e "${GREEN}Building Dashboard image...${NC}"
docker build \
-f docker/Dockerfile.dashboard \
-t ${DOCKERHUB_USERNAME}/dashboard:${VERSION} \
-t ${DOCKERHUB_USERNAME}/dashboard:latest \
.
echo -e "${GREEN}Pushing Dashboard image to Docker Hub...${NC}"
docker push ${DOCKERHUB_USERNAME}/dashboard:${VERSION}
docker push ${DOCKERHUB_USERNAME}/dashboard:latest
echo -e "${GREEN}✓ Dashboard image published successfully!${NC}"
# Summary
echo -e "${BLUE}================================${NC}"
echo -e "${GREEN}All images published successfully!${NC}"
echo -e "${BLUE}================================${NC}"
echo ""
echo "API Image: ${DOCKERHUB_USERNAME}/api:${VERSION}"
echo "Dashboard Image: ${DOCKERHUB_USERNAME}/dashboard:${VERSION}"
echo ""
echo "To pull these images:"
echo " docker pull ${DOCKERHUB_USERNAME}/api:${VERSION}"
echo " docker pull ${DOCKERHUB_USERNAME}/dashboard:${VERSION}
+215
View File
@@ -0,0 +1,215 @@
# Publishing ClassroomIO Docker Images to Docker Hub
This guide explains how to publish the ClassroomIO API and Dashboard Docker images to Docker Hub.
## Prerequisites
1. **Docker Hub Account**: Create an account at https://hub.docker.com if you don't have one
2. **Docker Installed**: Make sure Docker is installed and running on your machine
3. **Docker Hub Access Token**: Create a personal access token at https://hub.docker.com/settings/security
## Method 1: Manual Publishing (Local)
### Step 1: Login to Docker Hub
```bash
docker login
# Enter your Docker Hub username and password/token when prompted
```
### Step 2: Build and Push Images
Use the provided script to build and push both images:
```bash
# Default (uses 'classroomio' as username and 'latest' as version)
./docker/docker-push.sh
# Custom username
DOCKERHUB_USERNAME=your-username ./docker/docker-push.sh
# Specific version
VERSION=v0.1.0 ./docker/docker-push.sh
# Both custom username and version
DOCKERHUB_USERNAME=classroomio VERSION=v0.1.0 ./docker/docker-push.sh
```
### Step 3: Verify Images
Check your Docker Hub repository at:
- https://hub.docker.com/r/classroomio/api
- https://hub.docker.com/r/classroomio/dashboard
## Method 2: GitHub Actions (Automated)
### Setup (One-time)
1. **Add Docker Hub Secrets to GitHub**:
- Go to your GitHub repository → Settings → Secrets and variables → Actions
- Add two secrets:
- `DOCKERHUB_USERNAME`: Your Docker Hub username
- `DOCKERHUB_TOKEN`: Your Docker Hub access token
2. **Push the workflow file** (already created at `.github/workflows/docker-publish.yml`)
### Publishing via GitHub Actions
The workflow will automatically publish images when:
1. **Push to main/master branch**: Creates a `latest` tag
2. **Create a version tag**: Creates versioned tags
3. **Manual trigger**: Use the Actions tab in GitHub
#### Create a version tag and push:
```bash
git tag v0.1.0
git push origin v0.1.0
```
#### Manual trigger from GitHub:
1. Go to Actions tab in your repository
2. Select "Publish Docker Images" workflow
3. Click "Run workflow"
4. Enter optional version tag (or leave default 'latest')
## Method 3: Individual Image Building
### Build API Image
```bash
docker build -f docker/Dockerfile.api -t classroomio/api:latest .
docker push classroomio/api:latest
```
### Build Dashboard Image
```bash
docker build -f docker/Dockerfile.dashboard -t classroomio/dashboard:latest .
docker push classroomio/dashboard:latest
```
## Using Published Images
### Pull Images
```bash
docker pull classroomio/api:latest
docker pull classroomio/dashboard:latest
```
### Update docker-compose.yml
Instead of building locally, use the published images:
```yaml
services:
api:
image: classroomio/api:latest
# Remove the 'build' section
ports:
- "3081:3081"
environment:
# ... your environment variables
dashboard:
image: classroomio/dashboard:latest
# Remove the 'build' section
ports:
- "3082:3082"
environment:
# ... your environment variables
```
## Versioning Strategy
### Recommended Tags:
- `latest`: Most recent stable build
- `v0.1.0`: Specific semantic version
- `v1.0`: Minor version
- `v1`: Major version
- `main`: Latest main branch build
- `dev`: Development builds
### Example: Publishing a new version
```bash
# Build with multiple tags
docker build -f docker/Dockerfile.api \
-t classroomio/api:latest \
-t classroomio/api:v1.2.0 \
-t classroomio/api:v1.2 \
-t classroomio/api:v1 \
.
# Push all tags
docker push classroomio/api:latest
docker push classroomio/api:v1.2.0
docker push classroomio/api:v1.2
docker push classroomio/api:v1
```
## Image Sizes and Build Times
- **API Image**: ~400-600 MB (Node.js Alpine + dependencies)
- **Dashboard Image**: ~600-800 MB (Node.js Alpine + SvelteKit build)
- **Build Time**: 5-10 minutes per image (depending on your machine)
## Multi-Architecture Support
The GitHub Actions workflow builds for both `linux/amd64` and `linux/arm64` platforms, making the images compatible with:
- x86_64 servers
- ARM-based servers (like AWS Graviton)
- Apple Silicon Macs (M1/M2/M3)
## Troubleshooting
### "unauthorized: authentication required"
- Run `docker login` again
- Check your Docker Hub token hasn't expired
### "denied: requested access to the resource is denied"
- Make sure you're pushing to your own username
- Repository might need to be created first on Docker Hub
### Build failures
- Check Dockerfile syntax
- Ensure all build arguments are provided
- Check available disk space: `docker system df`
### Clean up old images
```bash
# Remove dangling images
docker image prune
# Remove all unused images
docker image prune -a
```
## Security Best Practices
1. **Never commit secrets** to the repository
2. **Use Docker Hub access tokens** instead of passwords
3. **Enable 2FA** on your Docker Hub account
4. **Scan images** for vulnerabilities:
```bash
docker scan classroomio/api:latest
```
5. **Use specific version tags** in production, not `latest`
## Support
For issues with publishing, please check:
- Docker Hub status: https://status.docker.com
- GitHub Actions logs in your repository
- Docker daemon logs: `docker info`
## References
- [Docker Hub Documentation](https://docs.docker.com/docker-hub/)
- [GitHub Actions for Docker](https://docs.github.com/en/actions/publishing-packages/publishing-docker-images)
- [Docker Build Documentation](https://docs.docker.com/engine/reference/commandline/build/)
+141
View File
@@ -0,0 +1,141 @@
# Docker Usage Guide
This directory contains all Docker-related files for the ClassroomIO project.
## 📁 Directory Structure
```
docker/
├── README.md # Full publishing guide
├── USAGE.md # This file - quick usage guide
├── commands.md # Quick command reference
├── docker-compose.yaml # Docker Compose configuration
├── docker-push.sh # Script to publish images to Docker Hub
├── Dockerfile.api # API service Dockerfile
└── Dockerfile.dashboard # Dashboard service Dockerfile
```
## 🚀 Quick Start
### Using Docker Compose (Recommended)
```bash
# From the project root
cd docker
docker-compose up -d
# Or from project root
docker-compose -f docker/docker-compose.yaml up -d
```
### Building Individual Images
```bash
# From project root
docker build -f docker/Dockerfile.api -t api .
docker build -f docker/Dockerfile.dashboard -t dashboard .
```
## 📦 Publishing to Docker Hub
```bash
# From project root
./docker/docker-push.sh
# With custom options
DOCKERHUB_USERNAME=your-username VERSION=v1.0.0 ./docker/docker-push.sh
```
## 🔧 Common Commands
### Start Services
```bash
cd docker && docker-compose up -d
```
### Stop Services
```bash
cd docker && docker-compose down
```
### View Logs
```bash
cd docker && docker-compose logs -f
```
### Rebuild Services
```bash
cd docker && docker-compose up -d --build
```
## 📚 Documentation
- **README.md**: Complete Docker Hub publishing guide
- **commands.md**: Quick command reference
- **GitHub Actions**: Automated publishing via `.github/workflows/docker-publish.yml`
## 🌍 Environment Variables
Copy `.env.example` to `.env` in the project root and configure:
```bash
cp ../.env.example ../.env
```
Required variables are listed in `docker-compose.yaml`.
## 🔗 Published Images
- **API**: `classroomio/api` or `classroomio/api`
- **Dashboard**: `classroomio/dashboard` or `classroomio/dashboard`
Pull from Docker Hub:
```bash
docker pull classroomio/api:latest
docker pull classroomio/dashboard:latest
```
## 💡 Tips
1. **Always run builds from project root** - the context needs to be the entire monorepo
2. **Use docker-compose** for local development
3. **Use published images** for production deployments
4. **Check logs** if services fail to start: `docker-compose logs -f`
## 🐛 Troubleshooting
### Services won't start
```bash
# Check logs
docker-compose logs -f
# Rebuild from scratch
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
### Port conflicts
```bash
# Check if ports are in use
lsof -i :3081 # API
lsof -i :3082 # Dashboard
# Change ports in docker-compose.yaml if needed
```
### Build failures
```bash
# Clean up Docker
docker system prune -a
# Try building again
docker-compose build --no-cache
```
## 📞 Support
For more detailed information, see:
- `README.md` - Full Docker Hub publishing guide
- `commands.md` - Quick command reference
- Project main README for general setup
+90
View File
@@ -0,0 +1,90 @@
# Quick Docker Commands Reference
## Login to Docker Hub
```bash
docker login
```
## Build Images Locally
### API
```bash
docker build -f ./docker/Dockerfile.api -t classroomio/api:latest .
```
### Dashboard
```bash
docker build -f ./docker/Dockerfile.dashboard -t classroomio/dashboard:latest .
```
## Push to Docker Hub
### API
```bash
docker push classroomio/api:latest
```
### Dashboard
```bash
docker push classroomio/dashboard:latest
```
## All in One Script
```bash
# Make script executable (first time only)
chmod +x docker-push.sh
# Run the script
./docker-push.sh
# With custom username
DOCKERHUB_USERNAME=your-username ./docker-push.sh
# With version tag
VERSION=v1.0.0 ./docker-push.sh
```
## Pull Published Images
```bash
docker pull classroomio/api:latest
docker pull classroomio/dashboard:latest
```
## Run Published Images
```bash
# API
docker run -d -p 3081:3081 \
-e PUBLIC_SUPABASE_ANON_KEY=your_key \
-e PUBLIC_SUPABASE_URL=your_url \
classroomio/api:latest
# Dashboard
docker run -d -p 3082:3082 \
-e PUBLIC_SUPABASE_ANON_KEY=your_key \
-e PUBLIC_SUPABASE_URL=your_url \
classroomio/dashboard:latest
```
## Useful Commands
### Check local images
```bash
docker images | grep classroomio
```
### Remove local images
```bash
docker rmi classroomio/api:latest
docker rmi classroomio/dashboard:latest
```
### View image details
```bash
docker inspect classroomio/api:latest
```
### Check image size
```bash
docker images classroomio/api:latest --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
```
+3 -2
View File
@@ -3,14 +3,15 @@
"version": "0.1.1", "version": "0.1.1",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"start": "turbo run start",
"dev": "turbo prepare --concurrency 13 && turbo run dev", "dev": "turbo prepare --concurrency 13 && turbo run dev",
"lint": "turbo run lint", "lint": "turbo run lint",
"clean": "turbo clean && rm -rf node_modules", "clean": "turbo clean && rm -rf node_modules",
"format": "prettier . --write", "format": "prettier . --write",
"release": "standard-version", "release": "standard-version",
"ci": "cypress run", "ci": "cypress run",
"dashboard:build": "cd apps/dashboard && pnpm i && PUBLIC_IS_SELFHOSTED=true NODE_OPTIONS='--max-old-space-size=20384' pnpm build", "dashboard:start": "PORT=3082 pnpm start --filter @cio/dashboard",
"dashboard:start": "cd apps/dashboard && node build", "api:start": "PORT=3081 pnpm start --filter @cio/api",
"supabase:push": "pnpx supabase link --project-ref $PROJECT_ID && pnpx supabase db push --include-all" "supabase:push": "pnpx supabase link --project-ref $PROJECT_ID && pnpx supabase db push --include-all"
}, },
"license": "MIT", "license": "MIT",
+4
View File
@@ -9,6 +9,10 @@
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", ".svelte-kit/**", ".vercel/**", "dist/**"] "outputs": [".next/**", "!.next/cache/**", ".svelte-kit/**", ".vercel/**", "dist/**"]
}, },
"start": {
"cache": false,
"persistent": true
},
"lint": { "lint": {
"dependsOn": ["^build"] "dependsOn": ["^build"]
}, },