diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c21f452 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# Dependencies +node_modules +npm-debug.log* + +# Production build (we'll create it in Docker) +dist +build + +# Version control +.git +.gitignore + +# IDE files +.vscode +.idea +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage + +# Docker files (don't copy Docker files into Docker) +Dockerfile +.dockerignore +docker-compose.yml + +# Documentation +README.md +*.md + +# GitHub +.github diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c89a7e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Multi-stage build for React app with Vite + +# Stage 1: Build the application +FROM node:22-alpine AS builder + +# Set working directory +WORKDIR /app + +# Copy package files first for better caching +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production=false + +# Copy source code +COPY . . + +COPY .env .env + +# Build the application +RUN npm run build + +# Stage 2: Serve the application with nginx +FROM nginx:alpine AS production + +# Install security updates +RUN apk upgrade + +# Create nginx user with specific UID for consistency +RUN addgroup -g 1001 -S nginx-group || true && \ + adduser -u 1001 -D -S -s /bin/false -G nginx-group nginx-user || true + +# Copy built assets from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +COPY ./nginx/nginx.conf /etc/nginx/nginx.conf + +# Set proper permissions +RUN chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + chown -R nginx:nginx /etc/nginx/conf.d + +RUN touch /var/run/nginx.pid && \ + chown -R nginx:nginx /var/run/nginx.pid + +# Switch to nginx user +USER nginx + +# Expose port 80 +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..3c0bd04 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,63 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + client_max_body_size 16M; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json; + + server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA routing + location / { + try_files $uri $uri/ /index.html; + } + + # Static files cache + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Health check + location /health { + access_log off; + add_header Content-Type text/plain; + return 200 "healthy\n"; + } + + # Deny sensitive files + location ~ /\.(?!well-known) { + deny all; + } + } +} diff --git a/package-lock.json b/package-lock.json index 4d95e05..f2cab96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,12 @@ "@mdxeditor/editor": "^3.42.0", "@piotr-cz/swr-idb-cache": "^1.1.2", "@tailwindcss/typography": "^0.5.15", - "@testing-library/jest-dom": "^6.8.0", "@tanstack/react-query": "^5.85.5", + "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", + "@types/lodash": "^4.17.20", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.7", "@types/react-transition-group": "^4.4.11", @@ -30,6 +31,7 @@ "epubjs": "^0.3.93", "framer-motion": "^12.23.12", "fuse.js": "^7.1.0", + "lodash": "^4.17.21", "react": "^19.1.1", "react-dom": "^19.1.1", "react-intersection-observer": "^9.16.0", @@ -123,6 +125,7 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -685,6 +688,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz", "integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -773,6 +777,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -782,6 +787,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1334,7 +1340,6 @@ "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", @@ -1350,7 +1355,6 @@ "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1361,7 +1365,6 @@ "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -1375,7 +1378,6 @@ "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1413,7 +1415,6 @@ "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1424,7 +1425,6 @@ "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" @@ -1518,6 +1518,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.0.0.tgz", "integrity": "sha512-obBEF+zd98r/KtKVW6A+8UGWeaOoyMpl6Q9P3FzHsOnsg742aXsl8v+H/zp09qSSu/a/Hxe9LNKzbBaQq1CEbA==", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "7.0.0" }, @@ -1568,7 +1569,6 @@ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18.0" } @@ -1579,7 +1579,6 @@ "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" @@ -1594,7 +1593,6 @@ "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -1609,7 +1607,6 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -1624,7 +1621,6 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -2068,6 +2064,7 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -3920,8 +3917,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4072,8 +4068,7 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/localforage": { "version": "0.0.34", @@ -4085,6 +4080,12 @@ "localforage": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -4114,6 +4115,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz", "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4123,6 +4125,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4215,6 +4218,7 @@ "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.40.0", "@typescript-eslint/types": "8.40.0", @@ -4546,6 +4550,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4568,7 +4573,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4591,7 +4595,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -4937,6 +4940,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -5029,7 +5033,6 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -5283,7 +5286,6 @@ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5419,8 +5421,7 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/define-data-property": { "version": "1.1.4", @@ -5531,8 +5532,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -6043,7 +6043,6 @@ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -6074,7 +6073,6 @@ "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -6088,7 +6086,6 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6117,7 +6114,6 @@ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -6136,7 +6132,6 @@ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -6150,7 +6145,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -6249,8 +6243,7 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -6287,16 +6280,14 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fastq": { "version": "1.19.1", @@ -6327,7 +6318,6 @@ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -6353,7 +6343,6 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -6371,7 +6360,6 @@ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -6385,8 +6373,7 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/follow-redirects": { "version": "1.15.11", @@ -6633,7 +6620,6 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -6647,7 +6633,6 @@ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -6983,7 +6968,6 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -7000,7 +6984,6 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -7018,7 +7001,6 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } @@ -7522,15 +7504,13 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/isomorphic.js": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", - "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -7798,24 +7778,21 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -7864,7 +7841,6 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -7884,7 +7860,6 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -7904,7 +7879,6 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", "license": "MIT", - "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -8193,7 +8167,6 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -9701,7 +9674,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -9744,7 +9716,6 @@ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -9761,7 +9732,6 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -9784,7 +9754,6 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -9835,7 +9804,6 @@ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -9846,7 +9814,6 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -9912,6 +9879,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9947,7 +9915,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -9957,7 +9924,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9972,7 +9938,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10034,7 +9999,6 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -10065,6 +10029,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10083,6 +10048,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10490,7 +10456,6 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -10740,7 +10705,6 @@ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -10754,7 +10718,6 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -11056,7 +11019,6 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" }, @@ -11118,6 +11080,7 @@ "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", "license": "MIT", + "peer": true, "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" @@ -11136,7 +11099,8 @@ "version": "4.1.12", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.2.2", @@ -11214,6 +11178,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11293,7 +11258,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -11384,6 +11348,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11563,7 +11528,6 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -11692,6 +11656,7 @@ "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -11785,6 +11750,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11820,7 +11786,6 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -11933,7 +11898,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11969,7 +11933,6 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 3e9f27e..e3323e6 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", + "@types/lodash": "^4.17.20", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.7", "@types/react-transition-group": "^4.4.11", @@ -25,6 +26,7 @@ "epubjs": "^0.3.93", "framer-motion": "^12.23.12", "fuse.js": "^7.1.0", + "lodash": "^4.17.21", "react": "^19.1.1", "react-dom": "^19.1.1", "react-intersection-observer": "^9.16.0", diff --git a/src/App.tsx b/src/App.tsx index 15c5859..99eba01 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,12 @@ import { import { SpeedInsights } from "@vercel/speed-insights/react"; import { AnimatePresence, motion } from "framer-motion"; import { Analytics } from "@vercel/analytics/react"; +import { PageAnimationWrapper } from "./Components/PageAnimationWrapper"; +import { SWRConfig } from "swr"; +import { axiosFetcher } from "./Services/apiService"; +import { isAxiosError } from "axios"; +import { useCacheProvider } from "@piotr-cz/swr-idb-cache"; +import { useEffect } from "react"; // Styles import "./App.css"; @@ -32,6 +38,10 @@ import Dashboard from "./Pages/Dashboard"; import LoginPage from "./Pages/LoginPage"; import RegisterPage from "./Pages/RegisterPage"; import UploadPage from "./Pages/UploadPage"; +import EditNovelPage from "./Pages/EditNovelPage"; +import EditChapterPage from "./Pages/EditChapterPage"; +import UploadEPUBPage from "./Pages/UploadEPUBPage"; +import UploadNovelPage from "./Pages/UploadNovelPage"; // Components import { @@ -39,16 +49,6 @@ import { EditPageAccess, RequireUser, } from "./Components/AuthGuard"; -import EditNovelPage from "./Pages/EditNovelPage"; -import EditChapterPage from "./Pages/EditChapterPage"; -import { PageAnimationWrapper } from "./Components/PageAnimationWrapper"; -import UploadEPUBPage from "./Pages/UploadEPUBPage"; -import UploadNovelPage from "./Pages/UploadNovelPage"; -import { SWRConfig } from "swr"; -import { axiosFetcher } from "./Services/apiService"; -import { isAxiosError } from "axios"; -import { useCacheProvider } from "@piotr-cz/swr-idb-cache"; -import { useEffect } from "react"; function App() { const cacheProvider = useCacheProvider({ @@ -99,7 +99,18 @@ const RouterTransition = () => { return ( + }> + + + + } + /> + }> + {/* Main Page */} { } /> + {/* 404 Page */} { } /> + = ({ children }) => { try { const data = await Authenticate(); - if (!data.authenticated) { - return navigate("/login"); - } - - if (!data.user) { + if (!data) { return navigate("/login"); } } catch (err) { diff --git a/src/Components/ChapterCard.tsx b/src/Components/ChapterCard.tsx index 3cf920f..f60a373 100644 --- a/src/Components/ChapterCard.tsx +++ b/src/Components/ChapterCard.tsx @@ -1,41 +1,96 @@ -import React from "react"; -import { Link } from "react-router-dom"; +import React, { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; import { ChapterCardProps, NovelCardProps } from "../Types/types"; import FormattedTime from "./FormattedTime"; import useSWR from "swr"; import { useContent } from "../Contexts/ContentContext"; +import { getChapterProgress } from "../Services/cacheService"; +import { Popover } from "react-tiny-popover"; + +const ChapterCard: React.FC = ({ chapter }) => { + const [isAnimating, setIsAnimating] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); -const ChapterCard: React.FC = ({ chapter, index }) => { const { novel } = useContent(); if (!novel) return null; const { setChapter } = useContent(); + const navigate = useNavigate(); + const handleClick = () => { + setIsAnimating(true); + + setTimeout(() => { + setIsAnimating(false); + setChapter(chapter); + return navigate(`/novels/${novel.id}/${chapter.id}#root`); + }, 500); + }; + + const progress = getChapterProgress(String(chapter?.id ?? "")); + return ( -
- {chapter && ( -
-

{chapter.title}

-
- - { - setChapter(chapter); - }} - to={`/novels/${novel.id}/${chapter.id}#chapter-id`} - className="mx-1 link " - > - [Read] - + + [{Math.round(progress?.progress ?? 0)}% Read] +
+ } + > +
setIsPopoverOpen(true)} + onMouseLeave={() => setIsPopoverOpen(false)} + className="relative flex flex-col gap-6 p-2.5 my-2 overflow-x-clip" + > + {chapter && ( +
+

+ {chapter.title} +

+
+ +
{ + handleClick(); + }} + className="mx-1 link cursor-pointer" + > + [Read] +
+
+ )} + {isAnimating && ( +
+ )} + +
+ {/* Base bar */} +
+ + {/* Progress bar */} +
- )} -
+
+ ); }; diff --git a/src/Components/Navbar.tsx b/src/Components/Navbar.tsx index aa446bc..1ce6831 100644 --- a/src/Components/Navbar.tsx +++ b/src/Components/Navbar.tsx @@ -5,7 +5,6 @@ import { Link } from "react-router-dom"; import { useUser } from "../Contexts/UserContext"; import { Popover } from "react-tiny-popover"; import PersistentStoragePermissionButton from "./PersistentStoragePermissionButton"; -import SettingsDropdown from "./SettingsDropdown"; const Navbar = () => { const { user, logout } = useUser(); @@ -50,7 +49,6 @@ const Navbar = () => { {!dropdown && (
- {user && ( { className={`${dropdown ? "text-xl w-fit flex flex-col flex-nowrap items-end pb-4 pt-2 px-2 pl-6 border-t border-zinc-800" : "hidden"}`} > - + [Novels] + + {user && ( + + [Upload] + + )} + {user && user.type === "Admin" && ( [Dashboard] diff --git a/src/Components/NovelCard.tsx b/src/Components/NovelCard.tsx index 2131d6f..67b266f 100644 --- a/src/Components/NovelCard.tsx +++ b/src/Components/NovelCard.tsx @@ -1,47 +1,171 @@ -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { NovelCardProps } from "../Types/types"; import { useContent } from "../Contexts/ContentContext"; +import { useEffect, useRef, useState } from "react"; const NovelCard: React.FC = ({ novel, index }) => { - let hoverTimeout: ReturnType; - + const [progress, setProgress] = useState(0); + const [isHovering, setIsHovering] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const startTimeRef = useRef(0); + const animationFrameRef = useRef(0); + const timeoutRef = useRef(0); const { novels, setNovel } = useContent(); + const HOLD_DURATION = 1500; + const CLICK_DURATION = 500; + const navigate = useNavigate(); + + const updateProgress = ( + targetDuration: number, + isClick: boolean = false, + ) => { + if (startTimeRef.current) { + const elapsed = Date.now() - startTimeRef.current; + const newProgress = Math.min((elapsed / targetDuration) * 100, 100); + setProgress(newProgress); + + if (elapsed < targetDuration) { + animationFrameRef.current = requestAnimationFrame(() => + updateProgress(targetDuration, isClick), + ); + } else if (isClick) { + // Click animation complete - set novel AND navigate + setNovel(novel); + navigate(`/novels/${novel.id}#codex`); + } else { + // Hover animation complete - only set novel + setNovel(novel); + } + } + }; + + const handleClick = () => { + const currentProgress = progress; + const remainingProgress = 100 - currentProgress; + + setIsAnimating(true); + setIsHovering(false); + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + if (currentProgress >= (CLICK_DURATION / HOLD_DURATION) * 100) { + // Already past click threshold, finish quickly + const remainingTime = (remainingProgress / 100) * CLICK_DURATION; + startTimeRef.current = + Date.now() - (CLICK_DURATION - remainingTime); + animationFrameRef.current = requestAnimationFrame(() => + updateProgress(CLICK_DURATION, true), + ); + } else { + // Blend into click animation from current progress + const elapsedTime = (currentProgress / 100) * CLICK_DURATION; + startTimeRef.current = Date.now() - elapsedTime; + animationFrameRef.current = requestAnimationFrame(() => + updateProgress(CLICK_DURATION, true), + ); + } + }; const handleMouseEnter = () => { - hoverTimeout = setTimeout(() => { + if (isAnimating) return; + + setIsHovering(true); + startTimeRef.current = Date.now(); + + animationFrameRef.current = requestAnimationFrame(() => + updateProgress(HOLD_DURATION, false), + ); + + timeoutRef.current = setTimeout(() => { setNovel(novel); - }, 1000); + }, HOLD_DURATION); }; const handleMouseLeave = () => { - clearTimeout(hoverTimeout); + if (isAnimating) return; + + setIsHovering(false); + setProgress(0); + startTimeRef.current = 0; + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + const currentProgress = progress; + const decreaseStartTime = Date.now(); + + const animateDecrease = () => { + const elapsed = Date.now() - decreaseStartTime; + const newProgress = Math.max( + currentProgress - (elapsed / CLICK_DURATION) * 100, + 0, + ); + setProgress(newProgress); + + if (newProgress > 0) { + animationFrameRef.current = + requestAnimationFrame(animateDecrease); + } else { + startTimeRef.current = 0; + } + }; + + animationFrameRef.current = requestAnimationFrame(animateDecrease); }; + useEffect(() => { + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + return (
{novel && (
-

{novel.title}

+

+ {novel.title} +

{" > "} {novel.author}
- { - setNovel(novel); + handleClick(); }} - to={`/novels/${novel.id}#codex`} - className="mx-1 link" + className="mx-1 link cursor-pointer" > [Read] - +
)} + +
+
); }; diff --git a/src/Components/ProgressIndicator.tsx b/src/Components/ProgressIndicator.tsx new file mode 100644 index 0000000..543a0bb --- /dev/null +++ b/src/Components/ProgressIndicator.tsx @@ -0,0 +1,105 @@ +import { Query } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + getChapterProgress, + saveChapterProgress, +} from "../Services/cacheService"; +import { useContent } from "../Contexts/ContentContext"; +import { useError } from "../Contexts/ErrorContext"; +import { throttle } from "lodash"; +import { ChapterProgress } from "../Types/types"; + +const ProgressIndicator = () => { + const [scrollPosition, setScrollPosition] = useState(0); + const lastSavedProgressRef = useRef(0); + + const { id_chapter } = useParams(); + + const { chapter } = useContent(); + const { addError } = useError(); + + const progress = useMemo(() => { + return getChapterProgress(String(id_chapter ?? chapter?.id ?? "")); + }, [id_chapter, chapter?.id]); + + const calculateScrollPercentage = () => { + const winScroll = + document.body.scrollTop || document.documentElement.scrollTop; + const height = + document.documentElement.scrollHeight - + document.documentElement.clientHeight; + return height > 0 ? (winScroll / height) * 100 : 0; + }; + + const saveProgress = useCallback(() => { + const scrolled = calculateScrollPercentage(); + const winScroll = + document.body.scrollTop || document.documentElement.scrollTop; + + if ( + scrolled > lastSavedProgressRef.current || + lastSavedProgressRef.current === 0 + ) { + saveChapterProgress( + String(id_chapter ?? chapter?.id ?? ""), + scrolled, + winScroll, + ); + lastSavedProgressRef.current = scrolled; + } + }, [id_chapter, chapter?.id]); + + const throttledSave = useMemo( + () => throttle(saveProgress, 1000, { leading: false, trailing: true }), + [saveProgress], + ); + + useEffect(() => { + const saved = getChapterProgress( + String(id_chapter ?? chapter?.id ?? ""), + ); + + if (saved && saved.scrollPosition) { + if (document.readyState === "complete") { + window.scrollTo({ + top: saved.scrollPosition, + behavior: "smooth", + }); + } else { + window.addEventListener("load", () => { + window.scrollTo({ + top: saved.scrollPosition, + behavior: "smooth", + }); + }); + } + } + + const handleScroll = () => { + setScrollPosition(calculateScrollPercentage()); + throttledSave(); + }; + + window.addEventListener("scroll", handleScroll); + + return () => { + window.removeEventListener("scroll", handleScroll); + throttledSave.cancel(); + saveProgress(); + }; + }, [id_chapter, chapter?.id]); + + return ( +
+
+
+
+
+ ); +}; + +export default ProgressIndicator; diff --git a/src/Components/ScrollButtons.tsx b/src/Components/ScrollButtons.tsx index d5c7c9c..45c69bd 100644 --- a/src/Components/ScrollButtons.tsx +++ b/src/Components/ScrollButtons.tsx @@ -60,9 +60,9 @@ const ScrollButtons = () => { @@ -84,9 +84,9 @@ const ScrollButtons = () => { diff --git a/src/Components/SettingsDropdown.tsx b/src/Components/SettingsDropdown.tsx deleted file mode 100644 index f0d0335..0000000 --- a/src/Components/SettingsDropdown.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Popover } from "react-tiny-popover"; -import { useUser } from "../Contexts/UserContext"; -import { useState } from "react"; - -const SettingsDropdown = () => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const { - user, - colorScheme, - setColorScheme, - fontSize, - setFontSize, - sortBy, - setSortBy, - } = useUser(); - - return ( - setIsPopoverOpen(false)} - content={ -
setIsPopoverOpen(true)} - onMouseLeave={() => { - setTimeout(() => { - setIsPopoverOpen(false); - }, 3000); - }} - className="link main-background whitespace-nowrap p-2 border border-zinc-800 rounded" - > - {user && ( -
-

- [Style {colorScheme}] -

-

- [Font {fontSize}] -

-

- [Sort{" "} - {sortBy === "asc" ? "Ascending" : "Descending"}] -

-
- )} -
- } - > -
{ - setIsPopoverOpen(true); - }} - onMouseEnter={() => setIsPopoverOpen(true)} - onMouseLeave={() => { - setTimeout(() => { - setIsPopoverOpen(false); - }, 15000); - }} - className="text-lg link cursor-pointer" - > - [Settings] -
-
- ); -}; - -export default SettingsDropdown; diff --git a/src/Components/SettingsSidebar.tsx b/src/Components/SettingsSidebar.tsx new file mode 100644 index 0000000..ae4ced7 --- /dev/null +++ b/src/Components/SettingsSidebar.tsx @@ -0,0 +1,171 @@ +import { Popover } from "react-tiny-popover"; +import { useUser } from "../Contexts/UserContext"; +import { useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faGear } from "@fortawesome/free-solid-svg-icons"; + +const SettingsSidebar = () => { + const [isOpen, setIsOpen] = useState(false); + const [isFontOptionsOpen, setIsFontOptionsOpen] = useState(false); + const [isPaddingOptionsOpen, setIsPaddingOptionsOpen] = useState(false); + + const { + user, + colorScheme, + setColorScheme, + fontSize, + setFontSize, + sortBy, + setSortBy, + padding, + setPadding, + } = useUser(); + + return ( + +
+ setIsFontOptionsOpen(false)} + content={ +
+
+ setFontSize("xs")} + className="cursor-pointer mb-2" + > + [Extra Small] + + setFontSize("sm")} + className="cursor-pointer mb-2" + > + [Small] + + setFontSize("md")} + className="cursor-pointer mb-2" + > + [Medium] + + setFontSize("lg")} + className="cursor-pointer mb-2" + > + [Large] + + setFontSize("xl")} + className="cursor-pointer" + > + [Extra Large] + +
+
+ } + > +

+ setIsFontOptionsOpen(!isFontOptionsOpen) + } + > + [Font Size] +

+
+ + setIsPaddingOptionsOpen(false) + } + content={ +
+
+ { + setPadding( + String( + parseInt( + e.target.value, + ), + ), + ); + }} + style={{ direction: "rtl" }} + /> + + {100 - parseInt(padding)} + +
+ {Array.from( + { length: 11 }, + (_, i) => ( + + {i * 5} + + ), + )} +
+
+
+ } + > +

+ setIsPaddingOptionsOpen( + !isPaddingOptionsOpen, + ) + } + > + [Padding] +

+
+
+
+ } + > +
{ + setIsOpen(!isOpen); + }} + className="fixed top-1/2 right-1 rotate-90 cursor-pointer link flex items-center text-lg" + > + [ + + ] +
+ + ); +}; + +export default SettingsSidebar; diff --git a/src/Contexts/ContentContext.tsx b/src/Contexts/ContentContext.tsx index b06dda5..c5e2a2c 100644 --- a/src/Contexts/ContentContext.tsx +++ b/src/Contexts/ContentContext.tsx @@ -30,15 +30,15 @@ export const ContentProvider: React.FC<{ children: ReactNode }> = ({ const { addError } = useError(); const { setLoading } = useLoading(); - const { user } = useUser(); + const { sortBy } = useUser(); const [chapters, setChapters] = useState([]); const [chapterId, setChapterId] = useState(null); const [chapter, setChapter] = useState(null); const [novel, setNovel] = useState(null); - const key_n = user && `/all`; - const key_c = user && novel && chapterId && `/${novel.id}/${chapterId}`; + const key_n = `/all`; + const key_c = novel && chapterId && `/${novel.id}/${chapterId}`; const getKey = ( pageIndex: number, @@ -47,12 +47,12 @@ export const ContentProvider: React.FC<{ children: ReactNode }> = ({ if (!novel) return null; if (pageIndex === 0) { - return `/${novel.id}/chapters?order=desc`; + return `/${novel.id}/chapters?sort=${sortBy}`; } if (previousPageData && !previousPageData.next_cursor) return null; - return `/${novel.id}/chapters?cursor=${previousPageData?.next_cursor ?? ""}&order=desc`; + return `/${novel.id}/chapters?cursor=${previousPageData?.next_cursor ?? ""}&sort=${sortBy}`; }; const { @@ -103,14 +103,25 @@ export const ContentProvider: React.FC<{ children: ReactNode }> = ({ ? data_inf.flatMap((page) => page.chapters) : []; - const sortedChapters = allChapters.sort((a, b) => { - return b.title.localeCompare(a.title, undefined, { - numeric: true, - sensitivity: "base", + if (sortBy == "asc") { + const sortedChapters = allChapters.sort((a, b) => { + return a.title.localeCompare(b.title, undefined, { + numeric: true, + sensitivity: "base", + }); }); - }); - setChapters(sortedChapters); + setChapters(sortedChapters); + } else { + const sortedChapters = allChapters.sort((a, b) => { + return b.title.localeCompare(a.title, undefined, { + numeric: true, + sensitivity: "base", + }); + }); + + setChapters(sortedChapters); + } }, [data_inf]); useEffect(() => { diff --git a/src/Contexts/UserContext.tsx b/src/Contexts/UserContext.tsx index 3c20648..1f959a2 100644 --- a/src/Contexts/UserContext.tsx +++ b/src/Contexts/UserContext.tsx @@ -8,7 +8,7 @@ import React, { import api from "../Services/apiService"; import { Authenticate } from "../Services/authService"; import { HandleErr } from "../Services/errorHandler"; -import { User, UserContextType } from "../Types/types"; +import { User, UserContextType, ValidationResponse } from "../Types/types"; import { useError } from "./ErrorContext"; import { useLoading } from "./LoadingContext"; import { useNotification } from "./NotificationContext"; @@ -29,6 +29,7 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ const [colorScheme, setColorScheme] = useState("light"); const [fontSize, setFontSize] = useState("medium"); const [sortBy, setSortBy] = useState<"asc" | "desc">("desc"); + const [padding, setPadding] = useState("85"); useEffect(() => { const controller = new AbortController(); @@ -38,9 +39,9 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ try { const data = await Authenticate(); - if (data.authenticated) { + if (data) { setAuthenticated(true); - setUser(data.user); + setUser(data as User); setNotification("Logged back in"); } else { setUser(null); @@ -101,6 +102,8 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ setFontSize, sortBy, setSortBy, + padding, + setPadding, }} > {children} diff --git a/src/Layouts/ChapterPageLayout.tsx b/src/Layouts/ChapterPageLayout.tsx index 73a12d7..04cd851 100644 --- a/src/Layouts/ChapterPageLayout.tsx +++ b/src/Layouts/ChapterPageLayout.tsx @@ -1,7 +1,10 @@ import { Outlet } from "react-router-dom"; + import Footer from "../Components/Footer"; import Navbar from "../Components/Navbar"; import ScrollButtons from "../Components/ScrollButtons"; +import SettingsSidebar from "../Components/SettingsSidebar"; +import ProgressIndicator from "../Components/ProgressIndicator"; const ChapterPageLayout = () => { return ( @@ -10,6 +13,8 @@ const ChapterPageLayout = () => {
+ +
diff --git a/src/Layouts/HeroPageLayout.tsx b/src/Layouts/HeroPageLayout.tsx index d47f3ee..0ddb4e5 100644 --- a/src/Layouts/HeroPageLayout.tsx +++ b/src/Layouts/HeroPageLayout.tsx @@ -1,6 +1,5 @@ import { Outlet } from "react-router-dom"; -// Components import Navbar from "../Components/Navbar"; import Footer from "../Components/Footer"; @@ -11,7 +10,7 @@ const HeroPageLayout = () => { return (
-
+
diff --git a/src/Layouts/NovelPageLayout.tsx b/src/Layouts/NovelPageLayout.tsx index 5f62a5e..62da4ec 100644 --- a/src/Layouts/NovelPageLayout.tsx +++ b/src/Layouts/NovelPageLayout.tsx @@ -2,6 +2,7 @@ import { Outlet } from "react-router-dom"; import Navbar from "../Components/Navbar"; import Footer from "../Components/Footer"; +import ScrollButtons from "../Components/ScrollButtons"; const NovelPageLayout = () => { return ( @@ -10,6 +11,7 @@ const NovelPageLayout = () => {
+
); diff --git a/src/Layouts/NovelsPageLayout.tsx b/src/Layouts/NovelsPageLayout.tsx index 1350194..4a710ef 100644 --- a/src/Layouts/NovelsPageLayout.tsx +++ b/src/Layouts/NovelsPageLayout.tsx @@ -1,14 +1,13 @@ import { Outlet } from "react-router-dom"; -// Components import Navbar from "../Components/Navbar"; import Footer from "../Components/Footer"; const NovelsPageLayout = () => { return ( -
+
-
+