Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c02893371a | ||
|
|
5803d65a48 | ||
|
|
063b9c9510 | ||
|
|
27c6d86d50 | ||
|
|
bdd53f06c8 | ||
|
|
1d45442fed | ||
|
|
fb4cdeb0cd | ||
|
|
d255c1cce1 | ||
|
|
4ab06366a6 | ||
|
|
6008bf9039 | ||
|
|
eb74b5462b | ||
|
|
9f0aab5757 | ||
|
|
e3b3c03bef | ||
|
|
db8c3652b6 | ||
|
|
b87c1da035 | ||
|
|
8052ffae10 | ||
|
|
5acdab34c0 | ||
|
|
3cb91f0956 | ||
|
|
1e009ec98c | ||
|
|
51342444df | ||
|
|
f1304bd028 | ||
|
|
a66fbc2fa9 | ||
|
|
08ca6ac158 | ||
|
|
b0a6312db6 |
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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!
|
||||
@@ -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!
|
||||
@@ -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!
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
Generated
+329
-245
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}'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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 & badges">
|
||||
🖼️ {user.username}
|
||||
<Link href="/profile" className={cls("/profile")} aria-current={current("/profile")} title="My profile, drawings & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = ?")
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user