Author SHA1 Message Date
iamdoubz c02893371a Add dark mode, theming 2026-06-20 20:45:05 -05:00
iamdoubz 5803d65a48 Add profile changes for name, email, password, avatar 2026-06-20 12:46:25 -05:00
iamdoubz 063b9c9510 Add profile changes for name, email, password, avatar 2026-06-20 12:46:06 -05:00
iamdoubz 27c6d86d50 Details animation does not play cont 2026-06-20 05:38:25 -05:00
iamdoubz bdd53f06c8 Bump version
release-container / build-and-push (push) Successful in 1m16s
2026-06-20 05:16:30 -05:00
iamdoubz 1d45442fed Add documentation files 2026-06-20 05:13:55 -05:00
iamdoubz fb4cdeb0cd Merge pull request '[BUG] Details lesson does not show how to draw video' (#7) from bug_details_animation into main
Reviewed-on: #7
2026-06-20 05:13:02 -05:00
iamdoubz d255c1cce1 Details lesson loads your outline drawing as the canvas's base image, but that saved image was composited on an opaque white background, so it painted over the faint guide and the line-by-line video underneath 2026-06-20 05:11:03 -05:00
iamdoubz 4ab06366a6 Add package-lock.json file
release-container / build-and-push (push) Successful in 1m26s
2026-06-18 10:19:43 -05:00
iamdoubz 6008bf9039 Update dependencies 2026-06-18 10:19:16 -05:00
iamdoubz eb74b5462b Change npm install to npm ci 2026-06-18 10:18:57 -05:00
iamdoubz 9f0aab5757 Change npm ci to npm install 2026-06-18 10:16:46 -05:00
iamdoubz e3b3c03bef Substitute npm install for npm ci
release-container / build-and-push (push) Failing after 56s
2026-06-18 10:08:26 -05:00
iamdoubz db8c3652b6 Needs apt-get not apt 2026-06-18 10:05:47 -05:00
iamdoubz b87c1da035 Merge pull request '[Feature] Add docker compose' (#6) from feature_docker_compose into main
release-container / build-and-push (push) Failing after 1m6s
Reviewed-on: #6
2026-06-18 10:00:53 -05:00
iamdoubz 8052ffae10 Remove arm64 builds and qemu 2026-06-18 09:58:37 -05:00
iamdoubz 5acdab34c0 Bump dependencies to latest versions 2026-06-18 09:56:21 -05:00
iamdoubz 3cb91f0956 Add docker-compose.yml file 2026-06-18 09:55:40 -05:00
iamdoubz 1e009ec98c Add wget to image 2026-06-18 09:55:13 -05:00
iamdoubz 51342444df Merge pull request '[Bug] Coloring page and detail animations' (#5) from bug_coloring_animations into main
Reviewed-on: #5
2026-06-18 09:25:05 -05:00
iamdoubz f1304bd028 Ensure Details guide animations work 2026-06-18 09:21:54 -05:00
iamdoubz a66fbc2fa9 Fix some odd behavior when switching between colors and tools 2026-06-18 09:21:27 -05:00
iamdoubz 08ca6ac158 Merge pull request '[BUG] Fix redirects going to wrong pages in certain scenarios' (#4) from bug_redirect_lessons into main
release-container / build-and-push (push) Failing after 15m37s
Reviewed-on: #4
2026-06-18 00:50:44 -05:00
iamdoubz b0a6312db6 Fix redirects going to wrong pages in certain scenarios 2026-06-18 00:46:43 -05:00
34 changed files with 1234 additions and 325 deletions
+1 -4
View File
@@ -23,16 +23,13 @@ jobs:
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.dou.bet -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Install QEMU
uses: docker/setup-qemu-action@v3
- name: Install Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-arch image
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--platform linux/amd64 \
--tag $IMAGE:$TAG \
--tag $IMAGE:latest \
--cache-from type=registry,ref=$CACHE_IMAGE \
+4
View File
@@ -17,6 +17,10 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
RUN apt-get update \
&& apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev && npm cache clean --force
+25
View File
@@ -0,0 +1,25 @@
services:
drawit:
image: git.dou.bet/iamdoubz/drawit:latest
container_name: drawit
mem_limit: 256M
restart: always
ports:
- "127.0.0.1:3000:3000"
env_file:
- .env # Copy .env.example to .env and fill out
#-stack.env # If using Portainer, this needs to be named `stack.env`
environment:
NODE_ENV: production
PORT: 3000
volumes:
- drawit_data:/app/data
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1"]
interval: 120s
timeout: 5s
retries: 2
start_period: 40s
volumes:
drawit_data:
+23
View File
@@ -0,0 +1,23 @@
# Auto-starting DrawIt
After you know that DrawIt is up and running, you should have a mechanism that auto-start the service.
## SystemD
A common way to auto-start a service is to use systemd. There is a [dedicated file](SYSTEMD.md) for how to use this method.
## PM2
Another way to start the service is using something called PM2. This can be used for any npm/node project.
### Guide
1. Install PM2: `npm install pm2`
2. Install PM2 Logrotate: `npm install pm2-logrotate`
3. Start the server: `pm2 start npm --name "drawit" -- start`
You should now be running your very own DrawIt instance!
## Additional reading
After you know *it works!*, you should set up a [reverse proxy](REVERSE_PROXIES.md).
+29
View File
@@ -0,0 +1,29 @@
# Building guide
## Prerequisites
- [node](https://nodejs.org/en/download) v22.22.0+
- npm v10.9.4+
- git v2.34.1+
Other versions may work, they are simply untested.
## Guide
1. Clone the repo: `git clone https://git.dou.bet/iamdoubz/drawit.git`
2. Enter the repo: `cd drawit`
3. Install DrawIt's dependencies: `npm install --omit=dev`
4. Copy env example: `cp .env.example .env.local`
5. Edit `.env.local` to match your environment: `nano .env.local`
**NOTE:** be sure to generate a long and unique `SESSION_SECRET`. Recommendation: `openssl rand -hex 32`
6. Build the production assets: `npm run build`
7. OPTIONALLY, check the TypeScript files: `npm run typecheck`
8. Start the server: `npm run start`
You should now be running your very own DrawIt instance!
## Additional reading
After you know *it works!*, you should set up an [init startup service](AUTO_STARTING.md) and [reverse proxy](REVERSE_PROXIES.md).
+19
View File
@@ -0,0 +1,19 @@
# Docker installation
This guide does not cover how to install docker. Please use [docker's website](https://www.docker.com/get-started/) in order to find the best solution for you. If you already have docker installed, continue reading.
## Pre-built images
This is the recommended way by pulling images from the [repository](https://git.dou.bet/iamdoubz/drawit/packages/drawit).
### Docker-Compose
1. Copy the contents of `.env.example` and paste them into a file called `.env`
2. Change the variables to match your environment
3. Be sure to generate a long and secure SESSION_SECRET. My favorite is: `openssl rand -hex 32`
4. Download the `docker-compose.yml` file and run `sudo docker compose up -d`
5. DrawIt will be running on localhost on port 3000 by default
### Building your own images
If you really want to build your own image, you probably already know what you are doing. Best of luck!
+29
View File
@@ -0,0 +1,29 @@
# Documentation files
In this directory, you will find files relating to how DrawIt can be built, installed, auto-started, put behind a reverse proxy, and the thought process behind the creation of this application.
## Getting started
If you want to try out the lessons before you install your own instance, you can sign up at https://drawit.dou.bet.
If you are ready to self-host your own instance of DrawIt, keep reading!
## Docker
The easiest way to setup your own instance is to use docker. See [DOCKER.md](DOCKER.md) for more details.
## Building from source
If you do not want to use docker, you can build it from source. See [BUILDING.md](BUILDING.md) for an in depth guide.
## Reverse proxy
It is *highly* recommended to use a reverse proxy in front of DrawIt. See [REVERSE_PROXIES.md](REVERSE_PROXIES.md) for some helpful information on how to set this up.
## Auto-starting
If you do not use docker, you should auto start DrawIt on system boot. See [AUTO_STARTING.md](AUTO_STARTING.md) for some options on how to do this.
# Final thoughts
The documentation is a living source. If something seems off or incorrect, create a new issue or even better, fork and make a pull request!
+41
View File
@@ -0,0 +1,41 @@
# Reverse Proxy
This document will explain how to setup a reverse proxy for nginx/freenginx. Contributions for other reverse proxies are welcomed!
## General prerequisites
- A domain name pointed at your server e.g. `drawit.yourdomain.com`
- Certbot installed for Let's Encrypt certificates (`sudo apt install certbot python3-certbot-nginx`)
- Port-forwarding
## nginx/freenginx
### Prerequisites
- nginx/freenginx installed e.g. `sudo apt install nginx`
- A domain name pointed at your server e.g. `drawit.yourdomain.com`
- Certbot installed for Let's Encrypt certificates (`sudo apt install certbot python3-certbot-nginx`)
### Install the config file
Copy the provided config and substitute your domain:
```bash
sudo cp docs/drawit.conf /etc/nginx/sites-available/drawit.conf # OR sudo cp docs/drawit.conf /etc/nginx/conf.d/drawit.conf
sudo sed -i 's/your.domain.com/drawit.yourdomain.com/g' /etc/nginx/[sites-available|conf.d]/drawit.conf
```
Enable the site (older nginx versions <1.19):
```bash
sudo ln -s /etc/nginx/sites-available/drawit.conf /etc/nginx/sites-enabled/
```
Test and reload:
```bash
sudo nginx -t # must print "syntax is ok"
sudo nginx -s reload # loads changes/new files
```
Visit your site `drawit.yourdomain.com` and see if it loads!
+54
View File
@@ -0,0 +1,54 @@
# SystemD
There are many ways to create a systemd compatible service file. This guide is tailored to be "production ready".
## Guide
1. Create a new user named drawit: `sudo useradd --system --create-home --shell /usr/sbin/nologin drawit`
2. Create env directory: `sudo mkdir -p /etc/drawit`
3. Copy `.env.example` here: `sudo cp .env.example /etc/drawit/drawit.env`
4. Change directory to /opt: `cd /opt`
5. Follow instructions to [clone the repo](BUILDING.md#guide). Skip steps 4, 5, and 8.
6. Change permissions to drawit: `sudo chown -R drawit:drawit /opt/drawit /etc/drawit`
7. Create a new service file: `sudo nano /etc/systemd/system/drawit.service`
8. Paste in these contents:
```
[Unit]
Description=DrawIt
After=syslog.target network.target remote-fs.target nss-lookup.target network-online.target
Wants=network-online.target
Requires=nginx.service
[Service]
Type=simple
User=drawit
Group=drawit
WorkingDirectory=/opt/drawit
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/etc/drawit/drawit.env
ExecStart=/usr/bin/npm run start
RestartSec=5
KillSignal=SIGINT
TimeoutStopSec=30
SyslogIdentifier=drawit
TimeoutSec=30
StandardOutput=journal
StandardError=journal
Restart=always
[Install]
WantedBy=multi-user.target
```
9. If you want to use a different port, make sure to change it e.g. `Environment=PORT=10000`
10. OPTIONALLY reload system daemon: `sudo systemctl daemon-reload`
11. Enable and start the service: `sudo systemctl enable --now drawit.service`
12. Check the service for errors: `sudo systemctl status drawit.service`
You should now be running your very own DrawIt instance!
## Additional reading
After you know *it works!*, you should set up a [reverse proxy](REVERSE_PROXIES.md).
+100
View File
@@ -0,0 +1,100 @@
# /etc/nginx/conf.d/drawit.conf
#
# Assumes:
# - drawit is listening on 127.0.0.1:3000 (PORT=3000)
# - TLS is terminated here at nginx (recommended)
# - Certbot / Let's Encrypt has already issued a certificate
#
# Enable:
# sudo nginx -t
# sudo systemctl reload nginx
# ── Upstream ──────────────────────────────────────────────────────────────────
upstream drawit_backend {
server 127.0.0.1:3000;
# Keep a pool of connections to the backend so nginx does not open a new
# TCP connection for every WebSocket upgrade.
#keepalive 64;
}
server {
listen 80;
listen [::]:80;
server_name your.domain.com;
# Let's Encrypt ACME challenge — must be reachable over plain HTTP.
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Everything else redirects to HTTPS.
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name your.domain.com;
# Let's Encrypt Cert location
ssl_certificate /etc/letsencrypt/live/your.domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your.domain.com/privkey.pem;
# Modern TLS only — drops IE11 and very old Android.
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# Security Headers
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always; # Or DENY
add_header X-Permitted-Cross-Domain-Policies "none" always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "geolocation=(self), midi=(self), sync-xhr=(self), microphone=(self), camera=(self), magnetometer=(self), gyroscope=(self), fullscreen=(self), payment=(self), interest-cohort=()";
#add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; # Only enable this when you are ready!
# Logging
access_log /var/log/nginx/drawit_access.log;
error_log /var/log/nginx/drawit_error.log info;
# Or, disable logging altogether
#access_log off;
#error_log /dev/null crit;
# Proxy rules
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_ssl_verify off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_http_version 1.1;
proxy_pass http://drawit_backend;
}
# Block sensitive files
location ~* /(data|node_modules|src)/.*\.(db|db-shm|db-wal)$ {
deny all;
}
location ~* /(node_modules|src)/.*$ {
deny all;
}
# Block everything else
# Anything else gets a 444 (faster but confusing, change to 404 if you want) from nginx before it reaches the backend.
location ~* \.(php|asp|aspx|jsp|cgi|go|env|env.local|mod|sum|yaml|yml|md|gitignore|git|github|gitea|ts|mjs)$ {
return 444;
}
}
+329 -245
View File
@@ -1,20 +1,20 @@
{
"name": "drawit",
"version": "0.1.0",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "drawit",
"version": "0.1.0",
"version": "0.1.4",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.8.1",
"next": "15.1.12",
"nodemailer": "^6.9.16",
"better-sqlite3": "^12.11.1",
"next": "15.5.19",
"nodemailer": "^9.0.1",
"pdf-lib": "^1.17.1",
"react": "19.0.0",
"react-dom": "19.0.0"
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
@@ -35,10 +35,20 @@
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
@@ -54,13 +64,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
@@ -76,13 +86,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
@@ -96,9 +106,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
@@ -112,12 +122,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -128,12 +141,53 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -144,12 +198,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -160,12 +217,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -176,12 +236,15 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -192,12 +255,15 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -208,12 +274,15 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -226,16 +295,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -248,16 +320,69 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -270,16 +395,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -292,16 +420,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -314,16 +445,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -336,20 +470,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -358,10 +492,29 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
@@ -378,9 +531,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
@@ -397,15 +550,15 @@
}
},
"node_modules/@next/env": {
"version": "15.1.12",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.12.tgz",
"integrity": "sha512-EWKXZKsd9ZNn+gLqOtfwH2PQyWuWFmpLldjStw7mZgWgKu56vaqNkAGQrys2g5ER4CNXEDz3Khj2tD1pnsT9Uw==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz",
"integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.1.9",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.9.tgz",
"integrity": "sha512-sQF6MfW4nk0PwMYYq8xNgqyxZJGIJV16QqNDgaZ5ze9YoVzm4/YNx17X0exZudayjL9PF0/5RGffDtzXapch0Q==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz",
"integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==",
"cpu": [
"arm64"
],
@@ -419,9 +572,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.1.9",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.9.tgz",
"integrity": "sha512-fp0c1rB6jZvdSDhprOur36xzQvqelAkNRXM/An92sKjjtaJxjlqJR8jiQLQImPsClIu8amQn+ZzFwl1lsEf62w==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz",
"integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==",
"cpu": [
"x64"
],
@@ -435,12 +588,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.1.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.9.tgz",
"integrity": "sha512-77rYykF6UtaXvxh9YyRIKoaYPI6/YX6cy8j1DL5/1XkjbfOwFDfTEhH7YGPqG/ePl+emBcbDYC2elgEqY2e+ag==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz",
"integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -451,12 +607,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.1.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.9.tgz",
"integrity": "sha512-uZ1HazKcyWC7RA6j+S/8aYgvxmDqwnG+gE5S9MhY7BTMj7ahXKunpKuX8/BA2M7OvINLv7LTzoobQbw928p3WA==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz",
"integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -467,12 +626,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.1.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.9.tgz",
"integrity": "sha512-gQIX1d3ct2RBlgbbWOrp+SHExmtmFm/HSW1Do5sSGMDyzbkYhS2sdq5LRDJWWsQu+/MqpgJHqJT6ORolKp/U1g==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz",
"integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -483,12 +645,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.1.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.9.tgz",
"integrity": "sha512-fJOwxAbCeq6Vo7pXZGDP6iA4+yIBGshp7ie2Evvge7S7lywyg7b/SGqcvWq/jYcmd0EbXdb7hBfdqSQwTtGTPg==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz",
"integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -499,9 +664,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.1.9",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.9.tgz",
"integrity": "sha512-crfbUkAd9PVg9nGfyjSzQbz82dPvc4pb1TeP0ZaAdGzTH6OfTU9kxidpFIogw0DYIEadI7hRSvuihy2NezkaNQ==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz",
"integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==",
"cpu": [
"arm64"
],
@@ -515,9 +680,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.1.9",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.9.tgz",
"integrity": "sha512-SBB0oA4E2a0axUrUwLqXlLkSn+bRx9OWU6LheqmRrO53QEAJP7JquKh3kF0jRzmlYOWFZtQwyIWJMEJMtvvDcQ==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
"integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
"cpu": [
"x64"
],
@@ -548,12 +713,6 @@
"pako": "^1.0.10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -634,14 +793,17 @@
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
@@ -688,17 +850,6 @@
"ieee754": "^1.1.13"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
@@ -731,51 +882,6 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT",
"optional": true
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -884,13 +990,6 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT",
"optional": true
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@@ -943,15 +1042,13 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.1.12",
"resolved": "https://registry.npmjs.org/next/-/next-15.1.12.tgz",
"integrity": "sha512-fClyhVCGTATGYBnETgKAi7YU5+bSwzM5rqNsY3Dg5wBoBMwE0NSvWA3fzwYj0ijl+LMeiV8P2QAnUFpeqDfTgw==",
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz",
"integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==",
"license": "MIT",
"dependencies": {
"@next/env": "15.1.12",
"@swc/counter": "0.1.3",
"@next/env": "15.5.19",
"@swc/helpers": "0.5.15",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -963,19 +1060,19 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.1.9",
"@next/swc-darwin-x64": "15.1.9",
"@next/swc-linux-arm64-gnu": "15.1.9",
"@next/swc-linux-arm64-musl": "15.1.9",
"@next/swc-linux-x64-gnu": "15.1.9",
"@next/swc-linux-x64-musl": "15.1.9",
"@next/swc-win32-arm64-msvc": "15.1.9",
"@next/swc-win32-x64-msvc": "15.1.9",
"sharp": "^0.33.5"
"@next/swc-darwin-arm64": "15.5.19",
"@next/swc-darwin-x64": "15.5.19",
"@next/swc-linux-arm64-gnu": "15.5.19",
"@next/swc-linux-arm64-musl": "15.5.19",
"@next/swc-linux-x64-gnu": "15.5.19",
"@next/swc-linux-x64-musl": "15.5.19",
"@next/swc-win32-arm64-msvc": "15.5.19",
"@next/swc-win32-x64-msvc": "15.5.19",
"sharp": "^0.34.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
@@ -1009,9 +1106,9 @@
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -1137,24 +1234,24 @@
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.0.0"
"react": "^19.2.7"
}
},
"node_modules/readable-stream": {
@@ -1192,9 +1289,9 @@
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
@@ -1210,16 +1307,16 @@
}
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -1228,25 +1325,30 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/simple-concat": {
@@ -1294,16 +1396,6 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"optional": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1313,14 +1405,6 @@
"node": ">=0.10.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+10 -6
View File
@@ -1,6 +1,6 @@
{
"name": "drawit",
"version": "0.1.0",
"version": "0.1.4",
"private": true,
"description": "Free, open-source, privacy-first web app that teaches kids how to draw.",
"license": "MIT",
@@ -12,12 +12,12 @@
"lint": "next lint"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"next": "15.1.12",
"nodemailer": "^6.9.16",
"better-sqlite3": "^12.11.1",
"next": "15.5.19",
"nodemailer": "^9.0.1",
"pdf-lib": "^1.17.1",
"react": "19.0.0",
"react-dom": "19.0.0"
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
@@ -26,5 +26,9 @@
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"typescript": "^5.7.3"
},
"allowScripts": {
"better-sqlite3@12.11.1": true,
"sharp@0.34.5": true
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/session";
import { setDisplayName, setAvatar, setEmail, setPassword, checkPassword } from "@/lib/users";
import { tooLarge } from "@/lib/drawings";
import { EMAIL_RE } from "@/lib/validate";
export async function POST(req: Request) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Not signed in." }, { status: 401 });
let body: { action?: string; displayName?: string; avatar?: string; email?: string; current?: string; next?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
switch (body.action) {
case "profile": {
if (typeof body.displayName === "string") setDisplayName(user.id, body.displayName.slice(0, 40));
if (typeof body.avatar === "string") {
if (body.avatar && tooLarge(body.avatar)) return NextResponse.json({ error: "Image invalid or too large." }, { status: 413 });
setAvatar(user.id, body.avatar);
}
return NextResponse.json({ ok: true });
}
case "email": {
const email = (body.email || "").trim();
if (!EMAIL_RE.test(email)) return NextResponse.json({ error: "Please enter a valid email." }, { status: 400 });
if (!setEmail(user.id, email)) return NextResponse.json({ error: "That email is already in use." }, { status: 409 });
return NextResponse.json({ ok: true });
}
case "password": {
const current = body.current || "";
const next = body.next || "";
if (!checkPassword(user, current)) return NextResponse.json({ error: "Current password is incorrect." }, { status: 403 });
if (next.length < 6) return NextResponse.json({ error: "New password must be at least 6 characters." }, { status: 400 });
setPassword(user.id, next);
return NextResponse.json({ ok: true });
}
default:
return NextResponse.json({ error: "Unknown action." }, { status: 400 });
}
}
+39 -4
View File
@@ -19,6 +19,41 @@
--font: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
}
/* Dark theme — applied when data-theme="dark" is set on <html> (by the no-flash script / toggle). */
:root[data-theme="dark"] {
--bg: #17141f;
--surface: #221d31;
--ink: #f2eefb;
--ink-soft: #aaa2c0;
--primary: #7b9bff;
--primary-dark: #9db4ff;
--accent: #ff8ec0;
--sun: #ffce5b;
--sea: #43d3d3;
--leaf: #5fd083;
--danger: #ff7a7a;
--line: #36304a;
--shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
/* No-JS / pre-hydration fallback to the system preference */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg: #17141f;
--surface: #221d31;
--ink: #f2eefb;
--ink-soft: #aaa2c0;
--primary: #7b9bff;
--primary-dark: #9db4ff;
--accent: #ff8ec0;
--sun: #ffce5b;
--sea: #43d3d3;
--leaf: #5fd083;
--danger: #ff7a7a;
--line: #36304a;
--shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
}
* { box-sizing: border-box; }
html, body {
@@ -74,7 +109,7 @@ a { color: var(--primary-dark); }
}
.btn:active { transform: translateY(1px) scale(0.99); }
.btn:hover { filter: brightness(1.04); }
.btn.secondary { background: #fff; color: var(--primary-dark); border: 2px solid var(--line); box-shadow: none; }
.btn.secondary { background: var(--surface); color: var(--primary-dark); border: 2px solid var(--line); box-shadow: none; }
.btn.accent { background: var(--accent); }
.btn.ghost { background: transparent; color: var(--ink); box-shadow: none; }
.btn.danger { background: var(--danger); }
@@ -90,7 +125,7 @@ a { color: var(--primary-dark); }
/* Top navigation */
.nav {
position: sticky; top: 0; z-index: 20;
background: rgba(254, 249, 243, 0.9); backdrop-filter: blur(8px);
background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.nav-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 20px; max-width: var(--maxw); margin: 0 auto; }
@@ -98,7 +133,7 @@ a { color: var(--primary-dark); }
.brand .logo { font-size: 1.5rem; }
.nav-links { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.nav-links a { text-decoration: none; color: var(--ink); font-weight: 700; padding: 8px 14px; border-radius: 999px; border: 2px solid transparent; }
.nav-links a:hover { background: #fff; color: var(--ink); }
.nav-links a:hover { background: var(--surface); color: var(--ink); }
/* Current page: lighter fill, darker bold text for clear, readable contrast */
.nav-links a.active { background: #eaf0ff; color: var(--ink); font-weight: 800; }
/* Sign-up call-to-action: outlined so it doesn't read as the active page */
@@ -112,7 +147,7 @@ a { color: var(--primary-dark); }
.field input, .field select, .field textarea {
font: inherit; padding: 14px 16px; min-height: 52px;
border: 2px solid var(--line); border-radius: var(--radius-sm);
background: #fff; color: var(--ink); width: 100%;
background: var(--surface); color: var(--ink); width: 100%;
}
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--primary); }
.field textarea { min-height: 110px; resize: vertical; }
+7 -1
View File
@@ -44,9 +44,15 @@ export const viewport: Viewport = {
themeColor: "#dce06b",
};
// Runs before paint to set the theme (avoids a flash of the wrong colors).
const themeScript = `(function(){try{var p=localStorage.getItem('drawit-theme')||'system';var d=p==='dark'||(p==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.setAttribute('data-theme',d?'dark':'light');}catch(e){}})();`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>{children}</body>
</html>
);
+11 -4
View File
@@ -69,7 +69,7 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
<>
<SiteNav />
<main className="container page">
<TraceRunner meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} carryImage={carryImage} nextHref={nextHref} nextLabel={nextLabel} />
<TraceRunner key={lesson.slug} meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} carryImage={carryImage} nextHref={nextHref} nextLabel={nextLabel} />
</main>
</>
);
@@ -78,11 +78,18 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
// Coloring studio (extra)
if (lesson.phase === "extra") {
const meta = { ...common, baseSvg: lesson.baseSvg ?? "" };
// The user's own drawn sketch (from the Details lesson) — offered as an alternative to the stock line art.
let userSketch = "";
if (user) {
const subj = getSubjects(level).find((s) => s.key === lesson.subjectKey);
const d = subj?.lessons.find((l) => l.phase === "detail");
if (d) userSketch = getLatestDrawing(user.id, level, d.sublevel) ?? "";
}
return (
<>
<SiteNav />
<main className="container page">
<ColoringRunner meta={meta} loggedIn={!!user} username={user?.username ?? ""} alreadyEarned={alreadyEarned} />
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={!!user} username={user?.username ?? ""} alreadyEarned={alreadyEarned} userSketch={userSketch} />
</main>
</>
);
@@ -97,9 +104,9 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
<SiteNav />
<main className="container page">
{isShade ? (
<ShadingRunner meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} />
<ShadingRunner key={lesson.slug} meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} />
) : (
<LessonRunner meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} />
<LessonRunner key={lesson.slug} meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} />
)}
</main>
</>
+2 -2
View File
@@ -94,7 +94,7 @@ export default async function LearnPage() {
<details
key={subj.key}
open={subj.key === firstIncomplete}
style={{ border: subjComplete ? "2px solid var(--leaf)" : "2px solid var(--line)", borderRadius: 14, padding: "10px 14px", background: "#fff" }}
style={{ border: subjComplete ? "2px solid var(--leaf)" : "2px solid var(--line)", borderRadius: 14, padding: "10px 14px", background: "var(--surface)" }}
>
<summary style={{ cursor: "pointer", fontWeight: 800, fontSize: "1.05rem" }}>
{subj.emoji} {subj.name}
@@ -123,4 +123,4 @@ export default async function LearnPage() {
}
const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 });
const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "#fff" };
const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "var(--surface)" };
+2 -2
View File
@@ -34,7 +34,7 @@ export default function Home() {
</div>
</section>
<section className="section" style={{ background: "#fff", borderBlock: "1px solid var(--line)" }}>
<section className="section" style={{ background: "var(--surface)", borderBlock: "1px solid var(--line)" }}>
<div className="container">
<h2 className="center">Watch it drawn, line by line 🐟</h2>
<p className="center muted" style={{ maxWidth: 560, margin: "0 auto 26px" }}>
@@ -97,7 +97,7 @@ export default function Home() {
</div>
</section>
<section className="section" style={{ background: "#fff", borderTop: "1px solid var(--line)" }}>
<section className="section" style={{ background: "var(--surface)", borderTop: "1px solid var(--line)" }}>
<div className="container">
<h2 className="center">How DrawIt works</h2>
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", marginTop: 18 }}>
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { fileToDataURL } from "@/components/drawingPersistence";
const PRESETS = [
{ e: "🐟", bg: "#bfe9ff" }, { e: "🐼", bg: "#ececf2" }, { e: "🌸", bg: "#ffe0ef" }, { e: "🦄", bg: "#f3d9ff" },
{ e: "🐱", bg: "#ffe1c4" }, { e: "🐶", bg: "#ffe9b3" }, { e: "🐸", bg: "#d6f5cf" }, { e: "🦊", bg: "#ffd9c2" },
{ e: "🐧", bg: "#d9e6ff" }, { e: "🐢", bg: "#d9f0dd" }, { e: "🦋", bg: "#e7dbff" }, { e: "⭐", bg: "#fff0c2" },
];
function presetUrl(e: string, bg: string): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="50" fill="${bg}"/><text x="50" y="72" font-size="58" text-anchor="middle">${e}</text></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
const avatarBox: React.CSSProperties = { width: 56, height: 56, borderRadius: "50%", overflow: "hidden", cursor: "pointer", background: "#fff", padding: 0 };
export default function AccountSettings({
user,
drawings,
}: {
user: { username: string; displayName: string; email: string; avatar: string };
drawings: { id: number; image: string }[];
}) {
const router = useRouter();
const [displayName, setDisplayName] = useState(user.displayName);
const [avatar, setAvatar] = useState(user.avatar);
const [profileMsg, setProfileMsg] = useState("");
const [email, setEmail] = useState(user.email);
const [emailMsg, setEmailMsg] = useState("");
const [cur, setCur] = useState("");
const [next, setNext] = useState("");
const [pwMsg, setPwMsg] = useState("");
const uploadRef = useRef<HTMLInputElement | null>(null);
async function post(body: Record<string, unknown>) {
return fetch("/api/account", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
}
async function saveProfile() {
setProfileMsg("Saving…");
const r = await post({ action: "profile", displayName, avatar });
if (r.ok) { setProfileMsg("Saved! 🎉"); router.refresh(); } else setProfileMsg((await r.json()).error || "Couldn't save.");
}
async function saveEmail() {
setEmailMsg("Saving…");
const r = await post({ action: "email", email });
if (r.ok) { setEmailMsg("Email updated!"); router.refresh(); } else setEmailMsg((await r.json()).error || "Couldn't update.");
}
async function savePassword() {
setPwMsg("Saving…");
const r = await post({ action: "password", current: cur, next });
if (r.ok) { setPwMsg("Password changed!"); setCur(""); setNext(""); } else setPwMsg((await r.json()).error || "Couldn't change.");
}
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
try { setAvatar(await fileToDataURL(f, 256)); } catch { /* ignore */ }
if (uploadRef.current) uploadRef.current.value = "";
}
return (
<div className="stack">
{/* Avatar + display name */}
<div className="card">
<h2 style={{ marginTop: 0 }}>Profile</h2>
<div className="row" style={{ gap: 16, alignItems: "center" }}>
<div style={{ width: 80, height: 80, borderRadius: "50%", overflow: "hidden", background: "#eee", flex: "0 0 auto", display: "grid", placeItems: "center", border: "2px solid var(--line)" }}>
{avatar ? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : <span style={{ fontSize: "2rem", fontWeight: 800, color: "var(--ink-soft)" }}>{(displayName || user.username).slice(0, 1).toUpperCase()}</span>}
</div>
<div className="field" style={{ flex: 1, marginBottom: 0 }}>
<label htmlFor="dn">Display name</label>
<input id="dn" value={displayName} maxLength={40} onChange={(e) => setDisplayName(e.target.value)} placeholder={user.username} />
</div>
</div>
<p className="muted" style={{ margin: "16px 0 6px", fontWeight: 700, fontSize: "0.9rem" }}>Pick a cute avatar</p>
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
{PRESETS.map((p) => {
const url = presetUrl(p.e, p.bg);
return <button key={p.e} onClick={() => setAvatar(url)} title="Use this avatar" style={{ ...avatarBox, border: avatar === url ? "3px solid var(--primary)" : "2px solid var(--line)" }}><img src={url} alt="" style={{ width: "100%", height: "100%" }} /></button>;
})}
<input ref={uploadRef} type="file" accept="image/*" onChange={onUpload} style={{ display: "none" }} />
<button className="btn secondary" style={{ minHeight: 56, padding: "0 14px", boxShadow: "none" }} onClick={() => uploadRef.current?.click()}>📷 Upload</button>
{avatar && <button className="btn ghost" style={{ minHeight: 56, padding: "0 12px" }} onClick={() => setAvatar("")}>Remove</button>}
</div>
{drawings.length > 0 && (
<>
<p className="muted" style={{ margin: "16px 0 6px", fontWeight: 700, fontSize: "0.9rem" }}>…or use one of your drawings</p>
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
{drawings.slice(0, 12).map((d) => (
<button key={d.id} onClick={() => setAvatar(d.image)} title="Use this drawing" style={{ ...avatarBox, border: avatar === d.image ? "3px solid var(--primary)" : "2px solid var(--line)" }}>
<img src={d.image} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</button>
))}
</div>
</>
)}
<div className="row" style={{ marginTop: 16, alignItems: "center", gap: 12 }}>
<button className="btn" onClick={saveProfile}>Save profile</button>
{profileMsg && <span className="muted">{profileMsg}</span>}
</div>
</div>
{/* Email */}
<div className="card">
<h2 style={{ marginTop: 0 }}>Email</h2>
<div className="field"><label htmlFor="em">Email address</label><input id="em" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="row" style={{ alignItems: "center", gap: 12 }}>
<button className="btn" onClick={saveEmail}>Update email</button>
{emailMsg && <span className="muted">{emailMsg}</span>}
</div>
</div>
{/* Password */}
<div className="card">
<h2 style={{ marginTop: 0 }}>Password</h2>
<div className="field"><label htmlFor="cp">Current password</label><input id="cp" type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} /></div>
<div className="field"><label htmlFor="np">New password</label><input id="np" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} placeholder="At least 6 characters" /></div>
<div className="row" style={{ alignItems: "center", gap: 12 }}>
<button className="btn" onClick={savePassword}>Change password</button>
{pwMsg && <span className="muted">{pwMsg}</span>}
</div>
</div>
</div>
);
}
+20 -1
View File
@@ -19,6 +19,7 @@ interface Group {
export default function ProfileGallery({ groups }: { groups: Group[] }) {
const router = useRouter();
const [busyId, setBusyId] = useState<number | null>(null);
const [lightbox, setLightbox] = useState<string | null>(null);
async function remove(id: number) {
if (!window.confirm("Delete this drawing? This can't be undone.")) return;
@@ -62,7 +63,7 @@ export default function ProfileGallery({ groups }: { groups: Group[] }) {
>
{g.items.map((d) => (
<div key={d.id} style={{ border: "1px solid var(--line)", borderRadius: 12, overflow: "hidden", background: "#fff" }}>
<img src={d.image} alt={`${g.title} drawing`} style={{ width: "100%", display: "block", aspectRatio: "4 / 3", objectFit: "contain", background: "#fff" }} />
<img src={d.image} alt={`${g.title} drawing`} onClick={() => setLightbox(d.image)} style={{ width: "100%", display: "block", aspectRatio: "4 / 3", objectFit: "contain", background: "#fff", cursor: "zoom-in" }} />
<div style={{ padding: "8px 10px" }}>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<span className="pill role" style={{ fontSize: "0.72rem" }}>
@@ -84,6 +85,24 @@ export default function ProfileGallery({ groups }: { groups: Group[] }) {
</div>
</div>
))}
{lightbox && (
<div
onClick={() => setLightbox(null)}
role="dialog"
aria-modal="true"
style={{ position: "fixed", inset: 0, background: "rgba(20,16,30,0.82)", display: "grid", placeItems: "center", padding: 20, zIndex: 100, cursor: "zoom-out" }}
>
<img src={lightbox} alt="Drawing full size" style={{ maxWidth: "100%", maxHeight: "90vh", borderRadius: 12, background: "#fff", boxShadow: "0 12px 40px rgba(0,0,0,0.5)" }} />
<button
onClick={(e) => { e.stopPropagation(); setLightbox(null); }}
aria-label="Close"
style={{ position: "fixed", top: 16, right: 20, fontSize: "1.8rem", lineHeight: 1, background: "rgba(255,255,255,0.9)", border: "none", borderRadius: "50%", width: 44, height: 44, cursor: "pointer", fontWeight: 800 }}
>
×
</button>
</div>
)}
</div>
);
}
+20 -5
View File
@@ -44,7 +44,7 @@ export default async function ProfilePage() {
// Earned badges → rich cards
const earnedKeys = new Set(listBadges(user.id).map((b) => b.badge_key));
const awardedAt = new Map(listBadges(user.id).map((b) => [b.badge_key, b.awarded_at]));
const earned = LESSONS.filter((l) => earnedKeys.has(l.badgeKey)).map((l) => {
const earned = LESSONS.filter((l) => l.badgeKey && earnedKeys.has(l.badgeKey)).map((l) => {
const groupKey = `${l.level}-${l.sublevel}`;
const dates = datesByBadge.get(l.badgeKey) ?? [awardedAt.get(l.badgeKey) ?? ""];
return {
@@ -57,8 +57,8 @@ export default async function ProfilePage() {
};
});
// Badges still to collect
const locked = LESSONS.filter((l) => !earnedKeys.has(l.badgeKey)).map((l) => ({
// Badges still to collect (only badge-bearing lessons; one per subject)
const locked = LESSONS.filter((l) => l.badgeKey && !earnedKeys.has(l.badgeKey)).map((l) => ({
name: l.badgeName,
emoji: l.emoji,
}));
@@ -67,9 +67,24 @@ export default async function ProfilePage() {
<>
<SiteNav />
<main className="container page">
<h1>{user.username}&apos;s profile</h1>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
<div className="row" style={{ gap: 14, alignItems: "center" }}>
<div style={{ width: 64, height: 64, borderRadius: "50%", overflow: "hidden", background: "#eee", display: "grid", placeItems: "center", border: "2px solid var(--line)", flex: "0 0 auto" }}>
{user.avatar ? (
<img src={user.avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<span style={{ fontSize: "1.6rem", fontWeight: 800, color: "var(--ink-soft)" }}>{(user.display_name || user.username).slice(0, 1).toUpperCase()}</span>
)}
</div>
<div>
<h1 style={{ margin: 0 }}>{user.display_name || user.username}</h1>
{user.display_name && <p className="muted" style={{ margin: 0 }}>@{user.username}</p>}
</div>
</div>
<Link className="btn secondary" href="/profile/settings">⚙️ Settings</Link>
</div>
<section style={{ marginTop: 10 }}>
<section style={{ marginTop: 18 }}>
<h2>🏅 My badges</h2>
{earned.length === 0 ? (
<div className="card center">
+37
View File
@@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import SiteNav from "@/components/SiteNav";
import AccountSettings from "../AccountSettings";
import { getCurrentUser } from "@/lib/session";
import { listFinalDrawings } from "@/lib/drawings";
export const dynamic = "force-dynamic";
export const metadata = { title: "Settings · DrawIt" };
export default async function SettingsPage() {
const user = await getCurrentUser();
if (!user) redirect("/login");
const drawings = listFinalDrawings(user.id).map((d) => ({ id: d.id, image: d.image }));
return (
<>
<SiteNav />
<main className="container page form-narrow">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h1 style={{ margin: 0 }}>Settings</h1>
<Link className="btn ghost" href="/profile">← Back to profile</Link>
</div>
<AccountSettings
user={{
username: user.username,
displayName: user.display_name ?? "",
email: user.email,
avatar: user.avatar ?? "",
}}
drawings={drawings}
/>
</main>
</>
);
}
+74 -23
View File
@@ -1,7 +1,8 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL } from "./drawingPersistence";
import { useRouter } from "next/navigation";
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL, loadDrawingAsLayer } from "./drawingPersistence";
interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string }
@@ -31,9 +32,14 @@ function newLayer(id: number, name: string): Layer {
return { id, name, visible: true, canvas: c };
}
export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned }: {
meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean;
export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned, userSketch = "" }: {
meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; userSketch?: string;
}) {
const router = useRouter();
// Which line art to color: the stock template or the child's own sketch.
// If they have no sketch of their own, skip the choice and use the stock art.
const [sketch, setSketch] = useState<"stock" | "yours" | null>(userSketch ? null : "stock");
const [overlayUrl, setOverlayUrl] = useState<string>("");
const [done, setDone] = useState(false);
const [finalImage, setFinalImage] = useState<string | null>(null);
const [uploadMsg, setUploadMsg] = useState("");
@@ -47,6 +53,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
const committedRef = useRef<ImageData | null>(null); // active layer before current stroke
const undoRef = useRef<{ layerId: number; data: ImageData }[]>([]);
const drawingRef = useRef<{ x: number; y: number }[] | null>(null);
const pressureRef = useRef(0.7);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [, force] = useState(0);
@@ -78,21 +85,35 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
// Repaint when returning to the studio from the "done" screen
useEffect(() => { if (!done) composite(); }, [done, composite]);
// Rasterize the coloring-book template for the fill boundary + final export
// Build the line-art overlay (+ fill boundary + final export template) from the chosen source.
// "stock" = the curriculum's template SVG; "yours" = the child's own sketch (white knocked out to transparent so coloring shows under the lines).
useEffect(() => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" width="${W}" height="${H}">${meta.baseSvg}</svg>`;
const blob = new Blob([svg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
templImgRef.current = img;
const b = document.createElement("canvas"); b.width = W; b.height = H;
const bctx = b.getContext("2d")!; bctx.drawImage(img, 0, 0, W, H);
boundaryRef.current = bctx.getImageData(0, 0, W, H);
URL.revokeObjectURL(url);
};
img.src = url;
}, [meta.baseSvg]);
if (!sketch) return;
let alive = true;
(async () => {
let url: string;
if (sketch === "yours" && userSketch) {
const layer = await loadDrawingAsLayer(userSketch); // dark lines on transparent
const c = document.createElement("canvas"); c.width = W; c.height = H;
c.getContext("2d")!.drawImage(layer, 0, 0, W, H);
url = c.toDataURL("image/png");
} else {
url = "data:image/svg+xml;utf8," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" width="${W}" height="${H}">${meta.baseSvg}</svg>`);
}
if (!alive) return;
const img = new Image();
img.onload = () => {
if (!alive) return;
templImgRef.current = img;
const b = document.createElement("canvas"); b.width = W; b.height = H;
const bctx = b.getContext("2d")!; bctx.drawImage(img, 0, 0, W, H);
boundaryRef.current = bctx.getImageData(0, 0, W, H);
setOverlayUrl(url);
};
img.src = url;
})();
return () => { alive = false; };
}, [sketch, meta.baseSvg, userSketch]);
// Restore saved coloring into the bottom layer
useEffect(() => {
@@ -150,12 +171,14 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
if (committedRef.current) ctx.putImageData(committedRef.current, 0, 0);
const pts = drawingRef.current!;
if (!pts.length) return;
ctx.save(); // isolate this stroke's tool settings so they can't leak to the next stroke
strokeStyleFor(pressureRef.current);
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
if (pts.length === 1) ctx.lineTo(pts[0].x + 0.1, pts[0].y + 0.1);
else for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
ctx.restore();
composite();
}
@@ -194,13 +217,13 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
pushUndo();
committedRef.current = active.canvas.getContext("2d")!.getImageData(0, 0, W, H);
drawingRef.current = [p];
strokeStyleFor(e.pressure && e.pressure > 0 ? e.pressure : 0.7);
pressureRef.current = e.pressure && e.pressure > 0 ? e.pressure : 0.7;
renderStroke();
}
function onMove(e: React.PointerEvent) {
if (!drawingRef.current) return;
drawingRef.current.push(pt(e));
strokeStyleFor(e.pressure && e.pressure > 0 ? e.pressure : 0.7);
pressureRef.current = e.pressure && e.pressure > 0 ? e.pressure : 0.7;
renderStroke();
}
function onUp() { if (!drawingRef.current) return; drawingRef.current = null; committedRef.current = null; force((n) => n + 1); scheduleSave(); }
@@ -223,6 +246,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
}
if (hasContent()) { const data = flatten(true); setFinalImage(data); if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); }
setDone(true);
router.refresh(); // refresh cached Learn page with the new completion
}
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; if (!file) return;
@@ -233,6 +257,33 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
finally { if (uploadRef.current) uploadRef.current.value = ""; }
}
// Ask which line art to color before opening the studio (only when the child has a sketch of their own).
if (sketch === null) {
return (
<div>
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · Color it!</div>
<h1 style={{ margin: "2px 0" }}>{meta.emoji} {meta.title}</h1>
<p className="muted">Which drawing would you like to color in?</p>
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", marginTop: 12 }}>
<button onClick={() => setSketch("yours")} className="card" style={{ cursor: "pointer", textAlign: "center", border: "2px solid var(--line)" }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid var(--line)", overflow: "hidden", aspectRatio: "4 / 3" }}>
<img src={userSketch} alt="Your sketch" style={{ width: "100%", height: "100%", objectFit: "contain" }} />
</div>
<strong style={{ display: "block", marginTop: 10 }}>✏️ Use my sketch</strong>
<span className="muted" style={{ fontSize: "0.85rem" }}>Color the drawing you made</span>
</button>
<button onClick={() => setSketch("stock")} className="card" style={{ cursor: "pointer", textAlign: "center", border: "2px solid var(--line)" }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid var(--line)", overflow: "hidden", aspectRatio: "4 / 3" }}>
<svg viewBox="0 0 400 300" style={{ width: "100%", height: "100%" }} dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
</div>
<strong style={{ display: "block", marginTop: 10 }}>🖼️ Use the stock picture</strong>
<span className="muted" style={{ fontSize: "0.85rem" }}>Color our ready-made outline</span>
</button>
</div>
</div>
);
}
if (done) {
return (
<div className="form-narrow center">
@@ -261,7 +312,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
}
const swatch = (c: string, key: string) => (
<button key={key} onClick={() => { setColor(c); if (tool === "eraser" || tool === "fill") setTool("pen"); }} aria-label={`Color ${c}`}
<button key={key} onClick={() => setColor(c)} aria-label={`Color ${c}`}
style={{ width: 30, height: 30, borderRadius: "50%", background: c, border: color === c ? "3px solid var(--ink)" : "3px solid #fff", boxShadow: "0 0 0 1px var(--line)", cursor: "pointer" }} />
);
@@ -281,7 +332,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
<div style={{ position: "relative", width: "100%", maxWidth: 660, margin: "0 auto", aspectRatio: "4 / 3", background: "#fff", border: "2px solid var(--line)", borderRadius: 16, overflow: "hidden" }} className="no-touch-scroll">
<canvas ref={dispRef} width={W} height={H} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: tool === "fill" ? "pointer" : "crosshair" }} />
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }} dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
{overlayUrl && <img src={overlayUrl} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "fill", pointerEvents: "none" }} />}
</div>
{/* Tools */}
@@ -316,7 +367,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
<span style={{ width: 1, height: 28, background: "var(--line)" }} />
<label title="Create a color" style={{ display: "inline-flex", alignItems: "center", gap: 6, cursor: "pointer", fontSize: "0.82rem", fontWeight: 700 }} className="muted">
➕ New color
<input type="color" value={custom} onChange={(e) => { setCustom(e.target.value); setColor(e.target.value); if (tool === "eraser" || tool === "fill") setTool("pen"); }} style={{ width: 30, height: 30, border: "none", background: "none", cursor: "pointer" }} />
<input type="color" value={custom} onChange={(e) => { setCustom(e.target.value); setColor(e.target.value); }} style={{ width: 30, height: 30, border: "none", background: "none", cursor: "pointer" }} />
</label>
</div>
</div>
+5 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt";
import {
loadProgressDrawing,
@@ -70,6 +71,7 @@ export default function LessonRunner({
completedSteps: number[];
alreadyEarned: boolean;
}) {
const router = useRouter();
const lastIndex = steps.length - 1;
const startIndex = Math.min(completedSteps.length, lastIndex);
const [idx, setIdx] = useState(startIndex);
@@ -157,7 +159,7 @@ export default function LessonRunner({
if (!loggedIn) return;
const c = canvasRef.current;
if (!c || !hasContent()) return;
saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c));
saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c, 800, false));
}
function scheduleSave() {
if (!loggedIn) return;
@@ -196,7 +198,7 @@ export default function LessonRunner({
redraw();
force((n) => n + 1);
const c = canvasRef.current;
if (loggedIn && c) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c));
if (loggedIn && c) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c, 800, false));
}
function undo() {
strokesRef.current.pop();
@@ -288,6 +290,7 @@ export default function LessonRunner({
}
setEarned(loggedIn);
setFinished(true);
router.refresh();
}
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
+10 -3
View File
@@ -2,11 +2,12 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import LogoutButton from "./LogoutButton";
import ThemeToggle from "./ThemeToggle";
export default function NavLinks({
user,
}: {
user: { username: string; isAdmin: boolean } | null;
user: { username: string; isAdmin: boolean; displayName?: string; avatar?: string } | null;
}) {
const pathname = usePathname() || "/";
@@ -34,8 +35,13 @@ export default function NavLinks({
<Link href="/report" className={cls("/report")} aria-current={current("/report")}>
Report a problem
</Link>
<Link href="/profile" className={cls("/profile")} aria-current={current("/profile")} title="My drawings &amp; badges">
🖼️ {user.username}
<Link href="/profile" className={cls("/profile")} aria-current={current("/profile")} title="My profile, drawings &amp; badges" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
{user.avatar ? (
<img src={user.avatar} alt="" style={{ width: 24, height: 24, borderRadius: "50%", objectFit: "cover" }} />
) : (
"🖼️"
)}{" "}
{user.displayName || user.username}
</Link>
<LogoutButton />
</>
@@ -49,6 +55,7 @@ export default function NavLinks({
</Link>
</>
)}
<ThemeToggle />
</div>
);
}
+8 -6
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt";
import GripPressureDemo from "./GripPressureDemo";
import {
@@ -9,6 +10,7 @@ import {
saveFinalDrawing,
exportCanvasToDataURL,
fileToDataURL,
loadDrawingAsLayer,
} from "./drawingPersistence";
interface StepData { n: number; title: string; instruction: string; tip: string; guideSvg: string; }
@@ -33,6 +35,7 @@ export default function ShadingRunner({
}: {
meta: Meta; steps: StepData[]; loggedIn: boolean; username: string; completedSteps: number[]; alreadyEarned: boolean;
}) {
const router = useRouter();
const lastIndex = steps.length - 1;
const [idx, setIdx] = useState(Math.min(completedSteps.length, lastIndex));
const [finished, setFinished] = useState(alreadyEarned);
@@ -47,7 +50,7 @@ export default function ShadingRunner({
const stageRef = useRef<HTMLDivElement | null>(null);
const strokesRef = useRef<Stroke[]>([]);
const drawingRef = useRef<Stroke | null>(null);
const baseImgRef = useRef<HTMLImageElement | null>(null);
const baseImgRef = useRef<HTMLCanvasElement | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [toneIdx, setToneIdx] = useState(0);
const [size, setSize] = useState(SIZES[1]);
@@ -109,9 +112,7 @@ export default function ShadingRunner({
let alive = true;
loadProgressDrawing(meta.level, meta.sublevel).then((img) => {
if (!img || !alive) return;
const im = new Image();
im.onload = () => { baseImgRef.current = im; redraw(); };
im.src = img;
loadDrawingAsLayer(img).then((cv) => { if (alive) { baseImgRef.current = cv; redraw(); } });
});
return () => { alive = false; };
}, [loggedIn, alreadyEarned, meta.level, meta.sublevel, redraw]);
@@ -123,7 +124,7 @@ export default function ShadingRunner({
if (!loggedIn) return;
const c = canvasRef.current;
if (!c || !hasContent()) return;
saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c));
saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c, 800, false));
}
function scheduleSave() {
if (!loggedIn) return;
@@ -156,7 +157,7 @@ export default function ShadingRunner({
redraw();
force((n) => n + 1);
const c = canvasRef.current;
if (loggedIn && c) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c));
if (loggedIn && c) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c, 800, false));
}
function undo() { strokesRef.current.pop(); redraw(); force((n) => n + 1); scheduleSave(); }
@@ -182,6 +183,7 @@ export default function ShadingRunner({
}
setEarned(loggedIn);
setFinished(true);
router.refresh();
}
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
+1 -1
View File
@@ -10,7 +10,7 @@ export default async function SiteNav() {
<Link href="/" className="brand">
<span className="logo">🐟</span> DrawIt
</Link>
<NavLinks user={user ? { username: user.username, isAdmin: user.role === "admin" } : null} />
<NavLinks user={user ? { username: user.username, isAdmin: user.role === "admin", displayName: user.display_name ?? "", avatar: user.avatar ?? "" } : null} />
</div>
</nav>
);
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
type Pref = "system" | "light" | "dark";
const ORDER: Pref[] = ["system", "light", "dark"];
const ICON: Record<Pref, string> = { system: "🌓", light: "☀️", dark: "🌙" };
export default function ThemeToggle() {
const [pref, setPref] = useState<Pref>("system");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
try {
const p = localStorage.getItem("drawit-theme") as Pref | null;
if (p === "light" || p === "dark" || p === "system") setPref(p);
} catch {
/* ignore */
}
}, []);
// Apply the effective theme whenever the preference changes (and follow the system if "system").
useEffect(() => {
if (!mounted) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
const dark = pref === "dark" || (pref === "system" && mq.matches);
document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
};
apply();
if (pref === "system") {
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}
}, [pref, mounted]);
function cycle() {
const nextPref = ORDER[(ORDER.indexOf(pref) + 1) % ORDER.length];
setPref(nextPref);
try {
localStorage.setItem("drawit-theme", nextPref);
} catch {
/* ignore */
}
}
return (
<button
onClick={cycle}
title={`Theme: ${pref}${pref === "system" ? " (follows your device)" : ""} — tap to change`}
aria-label={`Theme: ${pref}`}
style={{
minHeight: 40,
padding: "8px 12px",
borderRadius: 999,
border: "2px solid var(--line)",
background: "transparent",
color: "var(--ink)",
cursor: "pointer",
fontWeight: 700,
}}
>
{/* avoid hydration mismatch: show a neutral icon until mounted */}
{mounted ? ICON[pref] : "🌓"}
</button>
);
}
+23 -13
View File
@@ -1,9 +1,10 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt";
import LineDraw from "./LineDraw";
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL } from "./drawingPersistence";
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL, loadDrawingAsLayer } from "./drawingPersistence";
interface StepData { n: number; title: string; instruction: string; tip: string; lines: string[] }
interface Meta {
@@ -20,10 +21,12 @@ export default function TraceRunner({ meta, steps, loggedIn, username, completed
meta: Meta; steps: StepData[]; loggedIn: boolean; username: string; completedSteps: number[]; alreadyEarned: boolean;
carryImage?: string; nextHref?: string; nextLabel?: string;
}) {
const router = useRouter();
const lastIndex = steps.length - 1;
const [idx, setIdx] = useState(Math.min(completedSteps.length, lastIndex));
const [finished, setFinished] = useState(alreadyEarned);
const [earned, setEarned] = useState(alreadyEarned);
// Always open the lesson (so you can redraw/continue); show the finished screen only after finishing here.
const [finished, setFinished] = useState(false);
const [earned, setEarned] = useState(false);
const [playKey, setPlayKey] = useState(0);
const [playing, setPlaying] = useState(true);
const [finalImage, setFinalImage] = useState<string | null>(null);
@@ -38,7 +41,7 @@ export default function TraceRunner({ meta, steps, loggedIn, username, completed
const stageRef = useRef<HTMLDivElement | null>(null);
const strokesRef = useRef<Stroke[]>([]);
const drawingRef = useRef<Stroke | null>(null);
const baseImgRef = useRef<HTMLImageElement | null>(null);
const baseImgRef = useRef<HTMLCanvasElement | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [color, setColor] = useState(COLORS[0]);
const [size, setSize] = useState(SIZES[1]);
@@ -69,12 +72,12 @@ export default function TraceRunner({ meta, steps, loggedIn, username, completed
useEffect(() => { if (!finished) resize(); }, [finished, resize]);
useEffect(() => {
if (!loggedIn || alreadyEarned) return; let alive = true;
if (!loggedIn) return; let alive = true;
loadProgressDrawing(meta.level, meta.sublevel).then((img) => {
// Use this lesson's own saved canvas if any; otherwise carry over the previous lesson's drawing
const src = img || carryImage;
if (!src || !alive) return;
const im = new Image(); im.onload = () => { baseImgRef.current = im; redraw(); }; im.src = src;
loadDrawingAsLayer(src).then((cv) => { if (alive) { baseImgRef.current = cv; redraw(); } });
});
return () => { alive = false; };
}, [loggedIn, alreadyEarned, meta.level, meta.sublevel, carryImage, redraw]);
@@ -83,14 +86,14 @@ export default function TraceRunner({ meta, steps, loggedIn, username, completed
useEffect(() => { setPlaying(true); setPlayKey((k) => k + 1); }, [idx]);
function hasContent() { return strokesRef.current.length > 0 || !!baseImgRef.current; }
function flushSave() { if (!loggedIn) return; const c = canvasRef.current; if (!c || !hasContent()) return; saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c)); }
function flushSave() { if (!loggedIn) return; const c = canvasRef.current; if (!c || !hasContent()) return; saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c, 800, false)); }
function scheduleSave() { if (!loggedIn) return; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(flushSave, 1200); }
function pe(e: React.PointerEvent): Point { const r = canvasRef.current!.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; }
function onDown(e: React.PointerEvent) { if (finished) return; (e.target as Element).setPointerCapture?.(e.pointerId); const s: Stroke = { color, size, points: [pe(e)] }; drawingRef.current = s; strokesRef.current.push(s); }
function onMove(e: React.PointerEvent) { const s = drawingRef.current; if (!s) return; s.points.push(pe(e)); redraw(); }
function onUp() { drawingRef.current = null; force((n) => n + 1); scheduleSave(); }
function clearCanvas() { strokesRef.current = []; baseImgRef.current = null; redraw(); force((n) => n + 1); const c = canvasRef.current; if (loggedIn && c) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c)); }
function clearCanvas() { strokesRef.current = []; baseImgRef.current = null; redraw(); force((n) => n + 1); const c = canvasRef.current; if (loggedIn && c) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c, 800, false)); }
function undo() { strokesRef.current.pop(); redraw(); force((n) => n + 1); scheduleSave(); }
async function saveStep(stepN: number, badgeKey?: string) {
@@ -111,6 +114,7 @@ export default function TraceRunner({ meta, steps, loggedIn, username, completed
}
setEarned(loggedIn && !!meta.badgeKey);
setFinished(true);
router.refresh(); // refresh cached pages (Learn, gating) with the new progress
}
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; if (!file) return;
@@ -215,13 +219,19 @@ export default function TraceRunner({ meta, steps, loggedIn, username, completed
<div style={{ display: "grid", gap: 18, gridTemplateColumns: "1fr", alignItems: "start" }}>
<div>
<div ref={stageRef} className="no-touch-scroll" style={{ position: "relative", width: "100%", maxWidth: 640, margin: "0 auto", aspectRatio: "4 / 3", background: "#fff", border: "2px solid var(--line)", borderRadius: 16, overflow: "hidden" }}>
{/* faint trace target (outline base + lines so far) */}
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.28, pointerEvents: "none" }}
dangerouslySetInnerHTML={{ __html: meta.baseSvg + "\n" + guideUpTo }} />
{/* the slowly-drawn video for the current step */}
{playing && <LineDraw lines={step.lines} playKey={playKey} onDone={() => setPlaying(false)} />}
{/* the already-drawn outline shown clearly so the child knows what they're adding to */}
{meta.baseSvg && (
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.5, pointerEvents: "none" }}
dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
)}
{/* faint trace target for this lesson's lines so far */}
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.3, pointerEvents: "none" }}
dangerouslySetInnerHTML={{ __html: guideUpTo }} />
{/* the child's drawing (transparent base so the guide + video show through) */}
<canvas ref={canvasRef} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: "crosshair" }} />
{/* the slowly-drawn video for the current step — on top so it always shows, even over the imported outline */}
{playing && <LineDraw lines={step.lines} playKey={playKey} onDone={() => setPlaying(false)} />}
</div>
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 10 }}>
+38 -3
View File
@@ -42,7 +42,7 @@ export async function saveFinalDrawing(
}
/** Composite the (transparent) canvas onto white and downscale, returning a PNG data URL. */
export function exportCanvasToDataURL(canvas: HTMLCanvasElement, maxW = 800): string {
export function exportCanvasToDataURL(canvas: HTMLCanvasElement, maxW = 800, white = true): string {
const scale = Math.min(1, maxW / canvas.width);
const w = Math.round(canvas.width * scale);
const h = Math.round(canvas.height * scale);
@@ -50,12 +50,47 @@ export function exportCanvasToDataURL(canvas: HTMLCanvasElement, maxW = 800): st
out.width = w;
out.height = h;
const ctx = out.getContext("2d")!;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, w, h);
// White background for saved/gallery images; transparent for in-progress canvases that are
// restored as a base layer (so the faint guide and the line-by-line video still show through).
if (white) {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, w, h);
}
ctx.drawImage(canvas, 0, 0, w, h);
return out.toDataURL("image/png");
}
/**
* Load a saved drawing as a transparent layer: the white background is knocked out so only the
* strokes remain. This lets a restored/imported drawing sit over the guide + line video without
* hiding them — works whether the image was saved on white or already transparent.
*/
export function loadDrawingAsLayer(dataUrl: string): Promise<HTMLCanvasElement> {
return new Promise((resolve, reject) => {
const im = new Image();
im.onerror = reject;
im.onload = () => {
const c = document.createElement("canvas");
c.width = im.naturalWidth || 800;
c.height = im.naturalHeight || 600;
const ctx = c.getContext("2d")!;
ctx.drawImage(im, 0, 0);
try {
const id = ctx.getImageData(0, 0, c.width, c.height);
const d = id.data;
for (let i = 0; i < d.length; i += 4) {
if (d[i] > 244 && d[i + 1] > 244 && d[i + 2] > 244) d[i + 3] = 0;
}
ctx.putImageData(id, 0, 0);
} catch {
/* cross-origin shouldn't happen for our own data URLs */
}
resolve(c);
};
im.src = dataUrl;
});
}
/** Read an uploaded image File into a downscaled JPEG data URL (keeps photos small). */
export function fileToDataURL(file: File, maxW = 1000): Promise<string> {
return new Promise((resolve, reject) => {
+10
View File
@@ -36,6 +36,12 @@ function createConnection(): Database.Database {
return db;
}
/** Add a column to an existing table if it isn't there yet (simple forward migration). */
function addColumn(db: Database.Database, table: string, col: string, type: string) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (!cols.some((c) => c.name === col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
}
function migrate(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
@@ -128,6 +134,10 @@ function migrate(db: Database.Database) {
CREATE INDEX IF NOT EXISTS idx_drawings_progress ON drawings(user_id, level, sublevel, status);
CREATE INDEX IF NOT EXISTS idx_completions_user ON completions(user_id);
`);
// Profile fields (added later — safe for existing databases)
addColumn(db, "users", "display_name", "TEXT");
addColumn(db, "users", "avatar", "TEXT");
}
export function getDb(): Database.Database {
+6
View File
@@ -11,6 +11,8 @@ export interface User {
level: string;
status: Status;
email_verified: number; // 0 | 1
display_name: string | null;
avatar: string | null; // data URL
created_at: string;
}
@@ -23,6 +25,8 @@ export interface PublicUser {
level: string;
status: Status;
email_verified: boolean;
display_name: string | null;
avatar: string | null;
created_at: string;
}
@@ -47,6 +51,8 @@ export function toPublicUser(u: User): PublicUser {
level: u.level,
status: u.status,
email_verified: !!u.email_verified,
display_name: u.display_name ?? null,
avatar: u.avatar ?? null,
created_at: u.created_at,
};
}
+17
View File
@@ -74,6 +74,23 @@ export function checkPassword(user: User, password: string): boolean {
return verifyPassword(password, user.password_salt, user.password_hash);
}
export function setDisplayName(userId: number, displayName: string): void {
getDb().prepare("UPDATE users SET display_name = ? WHERE id = ?").run(displayName.trim() || null, userId);
}
export function setAvatar(userId: number, avatar: string): void {
getDb().prepare("UPDATE users SET avatar = ? WHERE id = ?").run(avatar || null, userId);
}
/** Change email. Returns false if the new email is already used by someone else. */
export function setEmail(userId: number, email: string): boolean {
const e = email.trim();
const existing = getUserByEmail(e);
if (existing && existing.id !== userId) return false;
getDb().prepare("UPDATE users SET email = ? WHERE id = ?").run(e, userId);
return true;
}
export function activateUser(userId: number): void {
getDb()
.prepare("UPDATE users SET status = 'active', email_verified = 1 WHERE id = ?")
+1
View File
@@ -14,6 +14,7 @@
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}