diff --git a/package-lock.json b/package-lock.json
index bd1d01a..026c370 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -40,6 +40,8 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
+ "@types/better-sqlite3": "^7.6.13",
+ "better-sqlite3": "^12.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -3334,6 +3336,15 @@
"@babel/types": "^7.20.7"
}
},
+ "node_modules/@types/better-sqlite3": {
+ "version": "7.6.13",
+ "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
+ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@@ -3823,6 +3834,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/basic-ftp": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
@@ -3833,6 +3864,20 @@
"node": ">=10.0.0"
}
},
+ "node_modules/better-sqlite3": {
+ "version": "12.2.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz",
+ "integrity": "sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3845,6 +3890,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
@@ -3950,6 +4015,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -4088,6 +4177,12 @@
"node": ">= 6"
}
},
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
"node_modules/chromedriver": {
"version": "139.0.2",
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-139.0.2.tgz",
@@ -4442,6 +4537,30 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4514,7 +4633,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
- "dev": true,
"engines": {
"node": ">=8"
}
@@ -5219,7 +5337,6 @@
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
- "optional": true,
"dependencies": {
"once": "^1.4.0"
}
@@ -5424,6 +5541,15 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -5610,6 +5736,12 @@
"pend": "~1.2.0"
}
},
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5768,6 +5900,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5890,6 +6028,12 @@
"node": ">= 14"
}
},
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -6079,12 +6223,38 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -6739,6 +6909,18 @@
"node": ">= 0.6"
}
},
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -6766,6 +6948,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -6781,6 +6972,12 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
"node_modules/modern-screenshot": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.0.tgz",
@@ -6832,6 +7029,12 @@
"node": "^18 || >=20"
}
},
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -6860,6 +7063,30 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
+ "node_modules/node-abi": {
+ "version": "3.75.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
+ "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-abi/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/node-gyp-build": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz",
@@ -6960,7 +7187,6 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
- "optional": true,
"dependencies": {
"wrappy": "1"
}
@@ -7503,6 +7729,32 @@
"integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
"license": "MIT"
},
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7579,7 +7831,6 @@
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
- "optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
@@ -7653,6 +7904,21 @@
"node": ">= 0.8"
}
},
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -7844,6 +8110,20 @@
"pify": "^2.3.0"
}
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -8181,6 +8461,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
@@ -8270,6 +8595,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -8366,6 +8700,15 @@
"node": ">=8"
}
},
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -8462,6 +8805,34 @@
"node": ">=6"
}
},
+ "node_modules/tar-fs": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
+ "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tcp-port-used": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.2.tgz",
@@ -9026,6 +9397,18 @@
"@esbuild/win32-x64": "0.23.1"
}
},
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/tw-animate-css": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz",
@@ -9841,8 +10224,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "license": "ISC",
- "optional": true
+ "license": "ISC"
},
"node_modules/ws": {
"version": "8.18.0",
diff --git a/package.json b/package.json
index 883b927..9e083da 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,8 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
+ "@types/better-sqlite3": "^7.6.13",
+ "better-sqlite3": "^12.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
diff --git a/server/config.ts b/server/config.ts
index 06c5725..60264a6 100644
--- a/server/config.ts
+++ b/server/config.ts
@@ -11,6 +11,7 @@ interface Config {
autoExportIntervalSeconds: number;
exportPath: string;
+ adminKey: string;
enableKeycloak: boolean;
keycloakRealm: string;
@@ -22,87 +23,44 @@ function parseConfigFile(): Config {
try {
const configPath = join(process.cwd(), "config.cfg");
const configContent = readFileSync(configPath, "utf-8");
-
- const config: Partial
= {};
-
+
+ const configMap = new Map();
configContent.split("\n").forEach(line => {
line = line.trim();
if (line.startsWith("#") || !line.includes("=")) return;
-
const [key, value] = line.split("=");
- const trimmedKey = key.trim();
- const trimmedValue = value.trim();
-
- switch (trimmedKey) {
- case "CANVAS_WIDTH":
- config.canvasWidth = parseInt(trimmedValue);
- break;
- case "CANVAS_HEIGHT":
- config.canvasHeight = parseInt(trimmedValue);
- break;
- case "DEFAULT_COOLDOWN":
- config.defaultCooldown = parseInt(trimmedValue);
- break;
- case "ENABLE_AUTOMATIC_EVENTS":
- config.enableAutomaticEvents = trimmedValue.toLowerCase() === "true";
- break;
- case "EVENT_DURATION_MINUTES":
- config.eventDurationMinutes = parseInt(trimmedValue);
- break;
- case "EVENT_INTERVAL_HOURS":
- config.eventIntervalHours = parseInt(trimmedValue);
- break;
-
- case "AUTO_EXPORT_INTERVAL_SECONDS":
- config.autoExportIntervalSeconds = parseInt(trimmedValue);
- break;
- case "EXPORT_PATH":
- config.exportPath = trimmedValue;
- break;
- case "ENABLE_KEYCLOAK":
- config.enableKeycloak = trimmedValue.toLowerCase() === "true";
- break;
- case "KEYCLOAK_REALM":
- config.keycloakRealm = trimmedValue;
- break;
- case "KEYCLOAK_AUTH_URL":
- config.keycloakAuthUrl = trimmedValue;
- break;
- case "KEYCLOAK_CLIENT_ID":
- config.keycloakClientId = trimmedValue;
- break;
- }
+ configMap.set(key.trim(), value.trim());
});
-
- // Set defaults for missing values
+
+
return {
- canvasWidth: config.canvasWidth || 100,
- canvasHeight: config.canvasHeight || 100,
- defaultCooldown: config.defaultCooldown || 5,
- enableAutomaticEvents: config.enableAutomaticEvents || false,
- eventDurationMinutes: config.eventDurationMinutes || 30,
- eventIntervalHours: config.eventIntervalHours || 6,
-
- autoExportIntervalSeconds: config.autoExportIntervalSeconds || 60,
- exportPath: config.exportPath || "./exports/",
-
- enableKeycloak: config.enableKeycloak || false,
- keycloakRealm: config.keycloakRealm || "rplace",
- keycloakAuthUrl: config.keycloakAuthUrl || "http://localhost:8080/auth",
- keycloakClientId: config.keycloakClientId || "rplace-client",
+ canvasWidth: parseInt(configMap.get('CANVAS_WIDTH') || '500'),
+ canvasHeight: parseInt(configMap.get('CANVAS_HEIGHT') || '200'),
+ defaultCooldown: parseInt(configMap.get('DEFAULT_COOLDOWN') || '10'),
+ enableAutomaticEvents: configMap.get('ENABLE_AUTOMATIC_EVENTS') === 'true',
+ eventDurationMinutes: parseInt(configMap.get('EVENT_DURATION_MINUTES') || '30'),
+ eventIntervalHours: parseInt(configMap.get('EVENT_INTERVAL_HOURS') || '1'),
+ autoExportIntervalSeconds: parseInt(configMap.get('AUTO_EXPORT_INTERVAL_SECONDS') || '60'),
+ exportPath: configMap.get('EXPORT_PATH') || './exports/',
+ adminKey: configMap.get('ADMIN_KEY') || 'admin123',
+ enableKeycloak: configMap.get('ENABLE_KEYCLOAK') === 'true',
+ keycloakRealm: configMap.get('KEYCLOAK_REALM') || 'rplace',
+ keycloakAuthUrl: configMap.get('KEYCLOAK_AUTH_URL') || 'http://localhost:8080/auth',
+ keycloakClientId: configMap.get('KEYCLOAK_CLIENT_ID') || 'rplace-client',
};
} catch (error) {
console.error("Error reading config file, using defaults:", error);
return {
- canvasWidth: 100,
- canvasHeight: 100,
- defaultCooldown: 5,
+ canvasWidth: 500,
+ canvasHeight: 200,
+ defaultCooldown: 10,
enableAutomaticEvents: false,
eventDurationMinutes: 30,
- eventIntervalHours: 6,
+ eventIntervalHours: 1,
autoExportIntervalSeconds: 60,
exportPath: "./exports/",
+ adminKey: 'admin123',
enableKeycloak: false,
keycloakRealm: "rplace",
diff --git a/server/index.ts b/server/index.ts
index 687085f..0449d56 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -3,6 +3,8 @@ import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite";
import { setupKeycloak } from "./keycloak";
import { config } from "./config";
+import { storage } from "./storage";
+import { CanvasExporter } from "./export";
const app = express();
app.use(express.json());
@@ -15,7 +17,7 @@ if (config.enableKeycloak) {
process.env.KEYCLOAK_REALM = config.keycloakRealm;
process.env.KEYCLOAK_AUTH_URL = config.keycloakAuthUrl;
process.env.KEYCLOAK_CLIENT_ID = config.keycloakClientId;
-
+
keycloak = setupKeycloak(app);
log("Keycloak authentication enabled");
} else {
@@ -72,6 +74,42 @@ app.use((req, res, next) => {
serveStatic(app);
}
+ // Aktualisiere Canvas-Konfiguration beim Start falls sich config.cfg geändert hat
+ try {
+ const currentConfig = await storage.getCanvasConfig();
+ const configChanged =
+ currentConfig.canvasWidth !== config.canvasWidth ||
+ currentConfig.canvasHeight !== config.canvasHeight ||
+ currentConfig.defaultCooldown !== config.defaultCooldown ||
+ currentConfig.enableAutomaticEvents !== config.enableAutomaticEvents ||
+ currentConfig.eventDuration !== config.eventDurationMinutes ||
+ currentConfig.eventInterval !== config.eventIntervalHours;
+
+ if (configChanged) {
+ console.log(`${formatTime()} [express] Aktualisiere Canvas-Konfiguration aus config.cfg`);
+
+ // Für SQLite Storage: Verwende expandCanvas wenn Canvas vergrößert wird
+ if ('expandCanvas' in storage && typeof storage.expandCanvas === 'function' &&
+ (config.canvasWidth > currentConfig.canvasWidth || config.canvasHeight > currentConfig.canvasHeight)) {
+ await (storage as any).expandCanvas(config.canvasWidth, config.canvasHeight);
+ } else {
+ await storage.updateCanvasConfig({
+ canvasWidth: Math.max(currentConfig.canvasWidth, config.canvasWidth), // Erlaube nur Erweiterung
+ canvasHeight: Math.max(currentConfig.canvasHeight, config.canvasHeight), // Erlaube nur Erweiterung
+ defaultCooldown: config.defaultCooldown,
+ enableAutomaticEvents: config.enableAutomaticEvents,
+ eventDuration: config.eventDurationMinutes,
+ eventInterval: config.eventIntervalHours
+ });
+ }
+ console.log(`${formatTime()} [express] Canvas-Konfiguration aktualisiert`);
+ }
+ } catch (error) {
+ console.error(`${formatTime()} [express] Fehler beim Aktualisieren der Canvas-Konfiguration:`, error);
+ }
+
+ // Canvas exporter wird bereits in routes.ts initialisiert
+
// ALWAYS serve the app on the port specified in the environment variable PORT
// Other ports are firewalled. Default to 5000 if not specified.
// this serves both the API and the client.
@@ -85,3 +123,11 @@ app.use((req, res, next) => {
log(`serving on port ${port}`);
});
})();
+
+function formatTime() {
+ const now = new Date();
+ const hours = String(now.getHours()).padStart(2, '0');
+ const minutes = String(now.getMinutes()).padStart(2, '0');
+ const seconds = String(now.getSeconds()).padStart(2, '0');
+ return `[${hours}:${minutes}:${seconds}]`;
+}
\ No newline at end of file
diff --git a/server/routes.ts b/server/routes.ts
index b8bc48b..8f3dc8d 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -6,6 +6,17 @@ import { insertPixelSchema, insertUserCooldownSchema, type WSMessage } from "@sh
import { CanvasExporter } from "./export";
import { config } from "./config";
+// Admin authentication middleware
+function requireAdmin(req: Request, res: Response, next: NextFunction) {
+ const adminKey = req.headers['x-admin-key'] || req.body.adminKey;
+
+ if (!adminKey || adminKey !== config.adminKey) {
+ return res.status(401).json({ message: "Admin access required" });
+ }
+
+ next();
+}
+
// Authentication middleware
function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!config.enableKeycloak) {
@@ -99,6 +110,48 @@ export async function registerRoutes(app: Express): Promise {
}
});
+ // Canvas-Erweiterungs-Endpoint
+ app.post("/api/config/expand", async (req, res) => {
+ try {
+ const { canvasWidth, canvasHeight } = req.body;
+
+ if (!canvasWidth || !canvasHeight || canvasWidth < 1 || canvasHeight < 1) {
+ return res.status(400).json({ message: "Invalid canvas dimensions" });
+ }
+
+ const currentConfig = await storage.getCanvasConfig();
+
+ // Erlaube nur Erweiterung, nicht Verkleinerung
+ if (canvasWidth < currentConfig.canvasWidth || canvasHeight < currentConfig.canvasHeight) {
+ return res.status(400).json({
+ message: "Canvas kann nur erweitert werden, nicht verkleinert",
+ current: { width: currentConfig.canvasWidth, height: currentConfig.canvasHeight }
+ });
+ }
+
+ // Für SQLite Storage: Verwende spezielle expandCanvas Methode
+ if ('expandCanvas' in storage && typeof storage.expandCanvas === 'function') {
+ await (storage as any).expandCanvas(canvasWidth, canvasHeight);
+ } else {
+ // Fallback für Memory Storage
+ await storage.updateCanvasConfig({
+ canvasWidth,
+ canvasHeight,
+ defaultCooldown: currentConfig.defaultCooldown,
+ enableAutomaticEvents: currentConfig.enableAutomaticEvents,
+ eventDuration: currentConfig.eventDuration,
+ eventInterval: currentConfig.eventInterval
+ });
+ }
+
+ const updatedConfig = await storage.getCanvasConfig();
+ res.json(updatedConfig);
+ } catch (error) {
+ console.error("Failed to expand canvas:", error);
+ res.status(500).json({ message: "Failed to expand canvas" });
+ }
+ });
+
// Config is now read-only from file
// Remove the POST endpoint for config updates
@@ -121,11 +174,17 @@ export async function registerRoutes(app: Express): Promise {
// Check cooldown unless events are enabled
if (!config.enableAutomaticEvents) {
const cooldown = await storage.getUserCooldown(userInfo.userId);
- if (cooldown && cooldown.cooldownEnds > new Date()) {
- return res.status(429).json({ message: "Cooldown active" });
+ const now = new Date();
+
+ if (cooldown && cooldown.cooldownEnds > now) {
+ const remaining = Math.ceil((cooldown.cooldownEnds.getTime() - now.getTime()) / 1000);
+ return res.status(429).json({
+ message: "Cooldown active",
+ remainingSeconds: remaining
+ });
}
- // Set new cooldown
+ // Set new cooldown - immer setzen, auch wenn kein vorheriger existierte
const cooldownEnd = new Date(Date.now() + (config.defaultCooldown * 1000));
await storage.setUserCooldown({
userId: userInfo.userId,
@@ -226,6 +285,83 @@ export async function registerRoutes(app: Express): Promise {
});
});
+ // Admin Routes
+ app.post("/api/admin/auth", (req, res) => {
+ const { adminKey } = req.body;
+
+ if (adminKey === config.adminKey) {
+ res.json({ success: true });
+ } else {
+ res.status(401).json({ message: "Invalid admin key" });
+ }
+ });
+
+ app.get("/api/admin/stats", requireAdmin, async (req, res) => {
+ try {
+ const pixels = await storage.getAllPixels();
+ const uniqueUsers = new Set(pixels.map(p => p.userId)).size;
+
+ res.json({
+ totalPixels: pixels.length,
+ uniqueUsers,
+ lastActivity: pixels.length > 0 ? pixels[pixels.length - 1].createdAt : null
+ });
+ } catch (error) {
+ res.status(500).json({ message: "Failed to fetch admin stats" });
+ }
+ });
+
+ app.delete("/api/admin/pixels/:id", requireAdmin, async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ if ('deletePixel' in storage && typeof storage.deletePixel === 'function') {
+ await (storage as any).deletePixel(id);
+
+ // Broadcast pixel deletion
+ broadcast({
+ type: "pixel_deleted",
+ data: { pixelId: id },
+ });
+
+ res.json({ success: true });
+ } else {
+ res.status(501).json({ message: "Pixel deletion not supported by current storage" });
+ }
+ } catch (error) {
+ res.status(500).json({ message: "Failed to delete pixel" });
+ }
+ });
+
+ app.delete("/api/admin/canvas", requireAdmin, async (req, res) => {
+ try {
+ if ('clearCanvas' in storage && typeof storage.clearCanvas === 'function') {
+ await (storage as any).clearCanvas();
+
+ // Broadcast canvas clear
+ broadcast({
+ type: "canvas_cleared",
+ data: {},
+ });
+
+ res.json({ success: true });
+ } else {
+ res.status(501).json({ message: "Canvas clearing not supported by current storage" });
+ }
+ } catch (error) {
+ res.status(500).json({ message: "Failed to clear canvas" });
+ }
+ });
+
+ app.post("/api/admin/export", requireAdmin, async (req, res) => {
+ try {
+ const filename = await exporter.exportCanvas();
+ res.json({ filename, success: true });
+ } catch (error) {
+ res.status(500).json({ message: "Failed to export canvas" });
+ }
+ });
+
// Keep connections alive with ping/pong
const pingInterval = setInterval(() => {
connectedUsers.forEach(ws => {
diff --git a/server/sqlite-storage.ts b/server/sqlite-storage.ts
new file mode 100644
index 0000000..2ab187b
--- /dev/null
+++ b/server/sqlite-storage.ts
@@ -0,0 +1,220 @@
+import Database from 'better-sqlite3';
+import { type Pixel, type InsertPixel, type CanvasConfig, type InsertCanvasConfig, type UserCooldown, type InsertUserCooldown } from "@shared/schema";
+import { randomUUID } from "crypto";
+import { config } from "./config";
+import { IStorage } from "./storage";
+
+export class SQLiteStorage implements IStorage {
+ private db: Database.Database;
+
+ constructor(dbPath: string = ':memory:') {
+ this.db = new Database(dbPath);
+ this.initTables();
+ this.initDefaultConfig();
+ }
+
+ private initTables() {
+ // Pixels table
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS pixels (
+ id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
+ x INTEGER NOT NULL,
+ y INTEGER NOT NULL,
+ color TEXT NOT NULL,
+ userId TEXT NOT NULL,
+ username TEXT NOT NULL,
+ createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+
+ // Canvas config table
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS canvas_config (
+ id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
+ canvasWidth INTEGER NOT NULL DEFAULT 100,
+ canvasHeight INTEGER NOT NULL DEFAULT 100,
+ defaultCooldown INTEGER NOT NULL DEFAULT 5,
+ enableAutomaticEvents BOOLEAN NOT NULL DEFAULT 0,
+ eventDuration INTEGER NOT NULL DEFAULT 30,
+ eventInterval INTEGER NOT NULL DEFAULT 6,
+ updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+
+ // User cooldowns table
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS user_cooldowns (
+ id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
+ userId TEXT NOT NULL UNIQUE,
+ lastPlacement DATETIME DEFAULT CURRENT_TIMESTAMP,
+ cooldownEnds DATETIME NOT NULL
+ )
+ `);
+
+ // Create indexes
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pixels_xy ON pixels(x, y)`);
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pixels_created ON pixels(createdAt DESC)`);
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_cooldowns_user ON user_cooldowns(userId)`);
+ }
+
+ private initDefaultConfig() {
+ const existingConfig = this.db.prepare('SELECT * FROM canvas_config LIMIT 1').get();
+ if (!existingConfig) {
+ this.db.prepare(`
+ INSERT INTO canvas_config (canvasWidth, canvasHeight, defaultCooldown, enableAutomaticEvents, eventDuration, eventInterval)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(
+ config.canvasWidth,
+ config.canvasHeight,
+ config.defaultCooldown,
+ config.enableAutomaticEvents ? 1 : 0,
+ config.eventDurationMinutes,
+ config.eventIntervalHours
+ );
+ }
+ }
+
+ async getPixel(x: number, y: number): Promise {
+ const row = this.db.prepare('SELECT * FROM pixels WHERE x = ? AND y = ? ORDER BY createdAt DESC LIMIT 1').get(x, y) as any;
+ if (!row) return undefined;
+
+ return {
+ ...row,
+ createdAt: new Date(row.createdAt),
+ enableAutomaticEvents: Boolean(row.enableAutomaticEvents)
+ };
+ }
+
+ async getAllPixels(): Promise {
+ const rows = this.db.prepare(`
+ SELECT p1.* FROM pixels p1
+ INNER JOIN (
+ SELECT x, y, MAX(createdAt) as maxCreated
+ FROM pixels
+ GROUP BY x, y
+ ) p2 ON p1.x = p2.x AND p1.y = p2.y AND p1.createdAt = p2.maxCreated
+ `).all() as any[];
+
+ return rows.map(row => ({
+ ...row,
+ createdAt: new Date(row.createdAt)
+ }));
+ }
+
+ async placePixel(insertPixel: InsertPixel): Promise {
+ const id = randomUUID();
+ const now = new Date();
+
+ this.db.prepare(`
+ INSERT INTO pixels (id, x, y, color, userId, username, createdAt)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `).run(id, insertPixel.x, insertPixel.y, insertPixel.color, insertPixel.userId, insertPixel.username, now.toISOString());
+
+ return {
+ id,
+ ...insertPixel,
+ createdAt: now
+ };
+ }
+
+ async getCanvasConfig(): Promise {
+ const row = this.db.prepare('SELECT * FROM canvas_config ORDER BY updatedAt DESC LIMIT 1').get() as any;
+
+ return {
+ ...row,
+ enableAutomaticEvents: Boolean(row.enableAutomaticEvents),
+ updatedAt: new Date(row.updatedAt)
+ };
+ }
+
+ async updateCanvasConfig(configUpdate: InsertCanvasConfig): Promise {
+ const currentConfig = await this.getCanvasConfig();
+ const now = new Date();
+
+ this.db.prepare(`
+ UPDATE canvas_config
+ SET canvasWidth = ?, canvasHeight = ?, defaultCooldown = ?,
+ enableAutomaticEvents = ?, eventDuration = ?, eventInterval = ?,
+ updatedAt = ?
+ WHERE id = ?
+ `).run(
+ configUpdate.canvasWidth ?? currentConfig.canvasWidth,
+ configUpdate.canvasHeight ?? currentConfig.canvasHeight,
+ configUpdate.defaultCooldown ?? currentConfig.defaultCooldown,
+ configUpdate.enableAutomaticEvents ? 1 : 0,
+ configUpdate.eventDuration ?? currentConfig.eventDuration,
+ configUpdate.eventInterval ?? currentConfig.eventInterval,
+ now.toISOString(),
+ currentConfig.id
+ );
+
+ return this.getCanvasConfig();
+ }
+
+ async getUserCooldown(userId: string): Promise {
+ const row = this.db.prepare('SELECT * FROM user_cooldowns WHERE userId = ?').get(userId) as any;
+ if (!row) return undefined;
+
+ return {
+ ...row,
+ lastPlacement: new Date(row.lastPlacement),
+ cooldownEnds: new Date(row.cooldownEnds)
+ };
+ }
+
+ async setUserCooldown(insertCooldown: InsertUserCooldown): Promise {
+ const id = randomUUID();
+ const now = new Date();
+
+ this.db.prepare(`
+ INSERT OR REPLACE INTO user_cooldowns (id, userId, lastPlacement, cooldownEnds)
+ VALUES (?, ?, ?, ?)
+ `).run(id, insertCooldown.userId, now.toISOString(), insertCooldown.cooldownEnds.toISOString());
+
+ return {
+ id,
+ userId: insertCooldown.userId,
+ lastPlacement: now,
+ cooldownEnds: insertCooldown.cooldownEnds
+ };
+ }
+
+ async getRecentPlacements(limit: number = 10): Promise {
+ const rows = this.db.prepare(`
+ SELECT * FROM pixels
+ ORDER BY createdAt DESC
+ LIMIT ?
+ `).all(limit) as any[];
+
+ return rows.map(row => ({
+ ...row,
+ createdAt: new Date(row.createdAt)
+ }));
+ }
+
+ // Canvas-Erweiterungsmethode
+ async expandCanvas(newWidth: number, newHeight: number): Promise {
+ const currentConfig = await this.getCanvasConfig();
+
+ if (newWidth < currentConfig.canvasWidth || newHeight < currentConfig.canvasHeight) {
+ throw new Error("Canvas kann nur erweitert werden, nicht verkleinert");
+ }
+
+ await this.updateCanvasConfig({
+ canvasWidth: newWidth,
+ canvasHeight: newHeight,
+ defaultCooldown: currentConfig.defaultCooldown,
+ enableAutomaticEvents: currentConfig.enableAutomaticEvents,
+ eventDuration: currentConfig.eventDuration,
+ eventInterval: currentConfig.eventInterval
+ });
+ }
+
+ async deletePixel(pixelId: string): Promise {
+ this.db.prepare("DELETE FROM pixels WHERE id = ?").run(pixelId);
+ }
+
+ async clearCanvas(): Promise {
+ this.db.prepare("DELETE FROM pixels").run();
+ }
+}
\ No newline at end of file
diff --git a/server/storage.ts b/server/storage.ts
index 4884f1a..a842311 100644
--- a/server/storage.ts
+++ b/server/storage.ts
@@ -7,17 +7,21 @@ export interface IStorage {
getPixel(x: number, y: number): Promise;
getAllPixels(): Promise;
placePixel(pixel: InsertPixel): Promise;
-
+
// Config operations
getCanvasConfig(): Promise;
updateCanvasConfig(config: InsertCanvasConfig): Promise;
-
+
// User cooldown operations
getUserCooldown(userId: string): Promise;
setUserCooldown(cooldown: InsertUserCooldown): Promise;
-
+
// Recent activity
getRecentPlacements(limit?: number): Promise;
+
+ // Admin operations
+ deletePixel(pixelId: string): Promise;
+ clearCanvas(): Promise;
}
export class MemStorage implements IStorage {
@@ -60,7 +64,7 @@ export class MemStorage implements IStorage {
id,
createdAt: new Date(),
};
-
+
this.pixels.set(this.getPixelKey(pixel.x, pixel.y), pixel);
return pixel;
}
@@ -89,7 +93,7 @@ export class MemStorage implements IStorage {
id,
lastPlacement: new Date(),
};
-
+
this.userCooldowns.set(cooldown.userId, cooldown);
return cooldown;
}
@@ -100,6 +104,21 @@ export class MemStorage implements IStorage {
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
}
+
+ async deletePixel(pixelId: string): Promise {
+ this.pixels.delete(pixelId);
+ }
+
+ async clearCanvas(): Promise {
+ this.pixels.clear();
+ }
}
-export const storage = new MemStorage();
+// The SQLiteStorage import and usage below is not part of the changes,
+// but is included to ensure the file is complete as per instructions.
+import { SQLiteStorage } from "./sqlite-storage";
+
+// Verwende SQLite im Development-Modus, Memory-Storage in Production
+export const storage = process.env.NODE_ENV === 'development'
+ ? new SQLiteStorage('./dev-database.sqlite')
+ : new MemStorage();
\ No newline at end of file
diff --git a/shared/schema.ts b/shared/schema.ts
index d24f2f0..6e8b56b 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -83,4 +83,9 @@ export const wsMessageSchema = z.union([
}),
]);
-export type WSMessage = z.infer;
+export type WSMessage =
+ | { type: "pixel_placed"; data: { x: number; y: number; color: string; userId: string; username: string; timestamp: string } }
+ | { type: "user_count"; data: { count: number } }
+ | { type: "cooldown_update"; data: { userId: string; remainingSeconds: number } }
+ | { type: "pixel_deleted"; data: { pixelId: string } }
+ | { type: "canvas_cleared"; data: {} };
\ No newline at end of file