diff --git a/.env.example b/.env.example index c63a11d0..b86f88f6 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ DB_URL = file:database/local.db # location of local database DB_AUTH_TOKEN = # you can leave this empty for local development DB_VISITS_AUTH_TOKEN = # you can leave this empty for local development -DB_VISITS_URL = file:database/visits.db # location of local database with site visits \ No newline at end of file +DB_VISITS_URL = file:database/visits.db # location of local database with site visits +GITHUB_PRIVATE_KEY = # you can leave this empty for local development +REDIS_URL = # you can leave this empty for local development \ No newline at end of file diff --git a/.github/workflows/_deploy-reusable.yaml b/.github/workflows/_deploy-reusable.yaml index d77fd605..f1faeef9 100644 --- a/.github/workflows/_deploy-reusable.yaml +++ b/.github/workflows/_deploy-reusable.yaml @@ -22,6 +22,10 @@ on: required: true DB_VISITS_AUTH_TOKEN: required: true + _GITHUB_PRIVATE_KEY: + required: true + REDIS_URL: + required: true jobs: build-and-deploy: @@ -66,6 +70,8 @@ jobs: DB_AUTH_TOKEN: ${{ secrets.DB_AUTH_TOKEN }} DB_VISITS_URL: ${{ secrets.DB_VISITS_URL }} DB_VISITS_AUTH_TOKEN: ${{ secrets.DB_VISITS_AUTH_TOKEN }} + GITHUB_PRIVATE_KEY: ${{ secrets._GITHUB_PRIVATE_KEY }} + REDIS_URL: ${{ secrets.REDIS_URL }} run: pnpm build - name: Get latest commit message diff --git a/.github/workflows/deploy-preview-skip-db.yaml b/.github/workflows/deploy-preview-skip-db.yaml index 10082cc6..14067ce7 100644 --- a/.github/workflows/deploy-preview-skip-db.yaml +++ b/.github/workflows/deploy-preview-skip-db.yaml @@ -17,3 +17,5 @@ jobs: NETLIFY_ACCESS_TOKEN: ${{ secrets.NETLIFY_ACCESS_TOKEN }} DB_VISITS_URL: ${{ secrets.DB_VISITS_URL }} DB_VISITS_AUTH_TOKEN: ${{ secrets.DB_VISITS_AUTH_TOKEN }} + _GITHUB_PRIVATE_KEY: ${{ secrets._GITHUB_PRIVATE_KEY }} + REDIS_URL: ${{ secrets.REDIS_URL }} diff --git a/.github/workflows/deploy-preview.yaml b/.github/workflows/deploy-preview.yaml index d8c4f94b..f3140ce8 100644 --- a/.github/workflows/deploy-preview.yaml +++ b/.github/workflows/deploy-preview.yaml @@ -17,3 +17,5 @@ jobs: NETLIFY_ACCESS_TOKEN: ${{ secrets.NETLIFY_ACCESS_TOKEN }} DB_VISITS_URL: ${{ secrets.DB_VISITS_URL }} DB_VISITS_AUTH_TOKEN: ${{ secrets.DB_VISITS_AUTH_TOKEN }} + _GITHUB_PRIVATE_KEY: ${{ secrets._GITHUB_PRIVATE_KEY }} + REDIS_URL: ${{ secrets.REDIS_URL }} diff --git a/.github/workflows/deploy-prod-skip-db.yaml b/.github/workflows/deploy-prod-skip-db.yaml index 0c4cdcdc..bf30aa45 100644 --- a/.github/workflows/deploy-prod-skip-db.yaml +++ b/.github/workflows/deploy-prod-skip-db.yaml @@ -17,3 +17,5 @@ jobs: NETLIFY_ACCESS_TOKEN: ${{ secrets.NETLIFY_ACCESS_TOKEN }} DB_VISITS_URL: ${{ secrets.DB_VISITS_URL }} DB_VISITS_AUTH_TOKEN: ${{ secrets.DB_VISITS_AUTH_TOKEN }} + _GITHUB_PRIVATE_KEY: ${{ secrets._GITHUB_PRIVATE_KEY }} + REDIS_URL: ${{ secrets.REDIS_URL }} diff --git a/.github/workflows/deploy-prod.yaml b/.github/workflows/deploy-prod.yaml index 7702369f..23523747 100644 --- a/.github/workflows/deploy-prod.yaml +++ b/.github/workflows/deploy-prod.yaml @@ -17,3 +17,5 @@ jobs: NETLIFY_ACCESS_TOKEN: ${{ secrets.NETLIFY_ACCESS_TOKEN }} DB_VISITS_URL: ${{ secrets.DB_VISITS_URL }} DB_VISITS_AUTH_TOKEN: ${{ secrets.DB_VISITS_AUTH_TOKEN }} + _GITHUB_PRIVATE_KEY: ${{ secrets._GITHUB_PRIVATE_KEY }} + REDIS_URL: ${{ secrets.REDIS_URL }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24d4c668..10ac5384 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,17 +2,15 @@ There are three ways to contribute: -- [Submit Our Google Form](#option-1-submit-our-google-form) +- [Use the Suggestion Form](#option-1-use-the-suggestion-form) - [Create a Pull Request](#option-2-create-a-pull-request) - [Create an Issue](#option-3-create-an-issue) -## Option 1: Submit our Google Form +## Option 1: Use the Suggestion Form -The Google Form is the least technical way to submit new categories, properties, and implications. Issues and feature requests can also be submitted. This option does not require any knowledge of GitHub or coding, making it accessible to everyone. +On most pages of CatDat, you will find a suggestion form at the bottom. Use it to contribute new data, report an issue, or make a suggestion. After submission, the form automatically creates a GitHub issue, which we then review and try to resolve and implement. -[**Link to the Google Form**](https://forms.gle/8LBmro5FfSa43pD2A) - -We will then review your submission and try to implement it. +This option does not require any knowledge of GitHub or coding, making it accessible to everyone. It also does not require following any guidelines for adding new data (see below). ## Option 2: Create a Pull Request @@ -110,3 +108,7 @@ When contributing new data (categories, functors, properties, implications), ple ## Option 3: Create an Issue If you want to report a bug or submit a feature request, you can [**create an issue**](https://github.com/ScriptRaccoon/CatDat/issues/new). You will need a GitHub account for this. + +## Deprecated Options + +Before the Suggestion Form (Option 1) was introduced, you could use a [Google Form](https://forms.gle/8LBmro5FfSa43pD2A). It is deprecated and will be removed soon. diff --git a/package.json b/package.json index 7e2819b1..964c01a1 100644 --- a/package.json +++ b/package.json @@ -47,9 +47,12 @@ "@fortawesome/free-regular-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@libsql/client": "^0.17.2", + "@octokit/app": "^16.1.2", "chart.js": "^4.5.1", "es6-crawler-detect": "^4.0.2", + "ioredis": "^5.10.1", "katex": "^0.16.44", + "leo-profanity": "^1.9.0", "sql-template-tag": "^5.2.1", "svelte-chartjs": "^4.0.1", "svelte-fa": "^4.0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1fca712..91769b90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,15 +21,24 @@ importers: '@libsql/client': specifier: ^0.17.2 version: 0.17.2 + '@octokit/app': + specifier: ^16.1.2 + version: 16.1.2 chart.js: specifier: ^4.5.1 version: 4.5.1 es6-crawler-detect: specifier: ^4.0.2 version: 4.0.2 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 katex: specifier: ^0.16.44 version: 0.16.44 + leo-profanity: + specifier: ^1.9.0 + version: 1.9.0 sql-template-tag: specifier: ^5.2.1 version: 5.2.1 @@ -424,6 +433,9 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -509,6 +521,89 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@octokit/app@16.1.2': + resolution: {integrity: sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ==} + engines: {node: '>= 20'} + + '@octokit/auth-app@8.2.0': + resolution: {integrity: sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-app@9.0.3': + resolution: {integrity: sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-device@8.0.3': + resolution: {integrity: sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-user@6.0.2': + resolution: {integrity: sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==} + engines: {node: '>= 20'} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/auth-unauthenticated@7.0.3': + resolution: {integrity: sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + + '@octokit/oauth-app@8.0.3': + resolution: {integrity: sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg==} + engines: {node: '>= 20'} + + '@octokit/oauth-authorization-url@8.0.0': + resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-methods@6.0.2': + resolution: {integrity: sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/openapi-webhooks-types@12.1.0': + resolution: {integrity: sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.8': + resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@octokit/webhooks-methods@6.0.0': + resolution: {integrity: sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==} + engines: {node: '>= 20'} + + '@octokit/webhooks@14.2.0': + resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==} + engines: {node: '>= 20'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -656,6 +751,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aws-lambda@8.10.161': + resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -691,6 +789,9 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + chart.js@4.5.1: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} @@ -707,6 +808,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -722,10 +827,23 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + detect-libc@2.0.2: resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} engines: {node: '>=8'} @@ -760,6 +878,9 @@ packages: esrap@2.2.4: resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -777,6 +898,9 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + french-badwords-list@1.0.7: + resolution: {integrity: sha512-H1ziKs2PJh2+UXZ9oCGJ/rRQpsI9NBykGf2Sc7WaKaj1OnWFuBXfsvANTdRcfVmOghGQaUmRyZ1hJOPbDpy04Q==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -790,6 +914,10 @@ packages: engines: {node: '>=18'} hasBin: true + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -800,6 +928,9 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + json-with-bigint@3.5.8: + resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + katex@0.16.44: resolution: {integrity: sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==} hasBin: true @@ -808,6 +939,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + leo-profanity@1.9.0: + resolution: {integrity: sha512-vMrrrjsbT+fA5I/1rlBEVT5YjJsw1ASIVF8/xBEdZ6ylsg5AEIBrEvHxwe5XAmfVuTBi7aw2KcO2L3s9ddpKzw==} + engines: {node: '>=18'} + libsql@0.5.29: resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} cpu: [x64, arm64, wasm32, arm] @@ -890,6 +1025,12 @@ packages: locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -901,6 +1042,9 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -960,6 +1104,14 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -968,6 +1120,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + russian-bad-words@0.5.0: + resolution: {integrity: sha512-euNvEYki6iYYpkNbeudW+lEMMYGEmN7EBwVF8ezlbv0bZoQpVYB7W10cCeUIGV7Ed50sJynLQ0c559q5iI0ejQ==} + engines: {node: '>=10'} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -987,6 +1143,9 @@ packages: resolution: {integrity: sha512-lFdvXCOqWhV40A7w4oQVDyuaNFb5yO+dhsHStZzOdtDJWCBWYv4+hhATK5nPpY5v/T1OMVcLMPeN4519qIyb9Q==} engines: {node: '>=14'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + svelte-chartjs@4.0.1: resolution: {integrity: sha512-4z+0J+w/6ADH2Cy+/AnVek2HxRrznQ7dJfWTybc9BHm9//DCb1BmLrSE3NGDRDLj+kwJbKw2o1tPLBE3CmdHmw==} peerDependencies: @@ -1014,6 +1173,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -1037,6 +1200,12 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + universal-github-app-jwt@2.2.2: + resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} + + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + vite@8.0.5: resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1304,6 +1473,8 @@ snapshots: '@iarna/toml@2.2.5': {} + '@ioredis/commands@1.5.1': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1396,6 +1567,132 @@ snapshots: '@neon-rs/load@0.0.4': {} + '@octokit/app@16.1.2': + dependencies: + '@octokit/auth-app': 8.2.0 + '@octokit/auth-unauthenticated': 7.0.3 + '@octokit/core': 7.0.6 + '@octokit/oauth-app': 8.0.3 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/types': 16.0.0 + '@octokit/webhooks': 14.2.0 + + '@octokit/auth-app@8.2.0': + dependencies: + '@octokit/auth-oauth-app': 9.0.3 + '@octokit/auth-oauth-user': 6.0.2 + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + toad-cache: 3.7.0 + universal-github-app-jwt: 2.2.2 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-app@9.0.3': + dependencies: + '@octokit/auth-oauth-device': 8.0.3 + '@octokit/auth-oauth-user': 6.0.2 + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-device@8.0.3': + dependencies: + '@octokit/oauth-methods': 6.0.2 + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-user@6.0.2': + dependencies: + '@octokit/auth-oauth-device': 8.0.3 + '@octokit/oauth-methods': 6.0.2 + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/auth-unauthenticated@7.0.3': + dependencies: + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.3': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/oauth-app@8.0.3': + dependencies: + '@octokit/auth-oauth-app': 9.0.3 + '@octokit/auth-oauth-user': 6.0.2 + '@octokit/auth-unauthenticated': 7.0.3 + '@octokit/core': 7.0.6 + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/oauth-methods': 6.0.2 + '@types/aws-lambda': 8.10.161 + universal-user-agent: 7.0.3 + + '@octokit/oauth-authorization-url@8.0.0': {} + + '@octokit/oauth-methods@6.0.2': + dependencies: + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/openapi-webhooks-types@12.1.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.8': + dependencies: + '@octokit/endpoint': 11.0.3 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.8 + universal-user-agent: 7.0.3 + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@octokit/webhooks-methods@6.0.0': {} + + '@octokit/webhooks@14.2.0': + dependencies: + '@octokit/openapi-webhooks-types': 12.1.0 + '@octokit/request-error': 7.1.0 + '@octokit/webhooks-methods': 6.0.0 + '@opentelemetry/api@1.9.0': optional: true @@ -1502,6 +1799,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aws-lambda@8.10.161': {} + '@types/cookie@0.6.0': {} '@types/estree@1.0.8': {} @@ -1526,6 +1825,8 @@ snapshots: axobject-query@4.1.0: {} + before-after-hook@4.0.0: {} + chart.js@4.5.1: dependencies: '@kurkle/color': 0.3.4 @@ -1540,6 +1841,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + commander@8.3.0: {} cookie@1.1.1: {} @@ -1552,8 +1855,14 @@ snapshots: data-uri-to-buffer@4.0.1: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + deepmerge@4.3.1: {} + denque@2.1.0: {} + detect-libc@2.0.2: {} detect-libc@2.1.2: {} @@ -1629,6 +1938,8 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@typescript-eslint/types': 8.58.0 + fast-content-type-parse@3.0.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1642,6 +1953,9 @@ snapshots: dependencies: fetch-blob: 3.2.0 + french-badwords-list@1.0.7: + optional: true + fsevents@2.3.3: optional: true @@ -1651,6 +1965,20 @@ snapshots: husky@9.1.7: {} + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-reference@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -1660,12 +1988,19 @@ snapshots: js-base64@3.7.8: {} + json-with-bigint@3.5.8: {} + katex@0.16.44: dependencies: commander: 8.3.0 kleur@4.1.5: {} + leo-profanity@1.9.0: + optionalDependencies: + french-badwords-list: 1.0.7 + russian-bad-words: 0.5.0 + libsql@0.5.29: dependencies: '@neon-rs/load': 0.0.4 @@ -1732,6 +2067,10 @@ snapshots: locate-character@3.0.0: {} + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1740,6 +2079,8 @@ snapshots: mrmime@2.0.1: {} + ms@2.1.3: {} + nanoid@3.3.11: {} node-domexception@1.0.0: {} @@ -1779,6 +2120,12 @@ snapshots: readdirp@5.0.0: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + resolve-pkg-maps@1.0.0: {} rolldown@1.0.0-rc.12(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.1): @@ -1805,6 +2152,9 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + russian-bad-words@0.5.0: + optional: true + sade@1.8.1: dependencies: mri: 1.2.0 @@ -1821,6 +2171,8 @@ snapshots: sql-template-tag@5.2.1: {} + standard-as-callback@2.1.0: {} + svelte-chartjs@4.0.1(chart.js@4.5.1)(svelte@5.55.1): dependencies: chart.js: 4.5.1 @@ -1866,6 +2218,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + toad-cache@3.7.0: {} + totalist@3.0.1: {} tr46@0.0.3: {} @@ -1884,6 +2238,10 @@ snapshots: undici-types@7.18.2: {} + universal-github-app-jwt@2.2.2: {} + + universal-user-agent@7.0.3: {} + vite@8.0.5(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 diff --git a/src/components/SuggestionForm.svelte b/src/components/SuggestionForm.svelte new file mode 100644 index 00000000..b13c181d --- /dev/null +++ b/src/components/SuggestionForm.svelte @@ -0,0 +1,136 @@ + + +
+

Suggestion Form

+ +

+ Use the form below to report missing data, submit an issue, or make a suggestion. +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + {#if error} +

+ + Error: {error} +

+ {/if} + + {#if url} +

+ + Your suggestion has been created as a + GitHub issue. We will review it shortly. +

+ {/if} +
+ + diff --git a/src/lib/client/utils.ts b/src/lib/client/utils.ts index febf73e8..2f0e2253 100644 --- a/src/lib/client/utils.ts +++ b/src/lib/client/utils.ts @@ -1,4 +1,5 @@ import { goto } from '$app/navigation' +import type { Attachment } from 'svelte/attachments' export function get_device_type() { const w = window.innerWidth @@ -25,3 +26,21 @@ export function string_to_color(str: string): string { const h = hash % 360 return `hsl(${h}, 80%, 50%)` } + +export const resize_textarea: Attachment = (textarea) => { + if (!(textarea instanceof HTMLTextAreaElement)) return + + textarea.style.height = `${textarea.scrollHeight}px` + textarea.style.overflowY = 'hidden' + + const adjust = () => { + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight}px` + } + + textarea.addEventListener('input', adjust) + + return () => { + textarea.removeEventListener('input', adjust) + } +} diff --git a/src/lib/server/redis.ts b/src/lib/server/redis.ts new file mode 100644 index 00000000..1d1c5631 --- /dev/null +++ b/src/lib/server/redis.ts @@ -0,0 +1,54 @@ +import { dev } from '$app/environment' +import { REDIS_URL } from '$env/static/private' +import Redis from 'ioredis' +import profanity_filter from 'leo-profanity' + +export const redis = new Redis(REDIS_URL, { + tls: { rejectUnauthorized: !dev }, +}) + +const rate_limit_max = 2 +const rate_limit_window = 60 +const violation_window = 60 * 60 +const rate_limit_violation_limit = 5 +const profanity_violation_limit = 2 +const block_window = 60 * 60 * 24 * 30 + +export async function is_blocked(ip: string) { + return (await redis.get(`blocked:ip:${ip}`)) === '1' +} + +export async function rate_limit(ip: string) { + const key = `rate_limit:ip:${ip}` + + const count = await redis.incr(key) + + if (count === 1) { + await redis.expire(key, rate_limit_window) + } + + return count <= rate_limit_max +} + +export async function flag_violation(ip: string, type: 'rate_limit' | 'profanity') { + const key = `violation:ip:${ip}:${type}` + + const violations = await redis.incr(key) + + if (violations === 1) { + await redis.expire(key, violation_window) + } + + if (type === 'rate_limit' && violations >= rate_limit_violation_limit) { + await redis.set(`blocked:ip:${ip}`, '1', 'EX', block_window) + } + + if (type === 'profanity' && violations >= profanity_violation_limit) { + await redis.set(`blocked:ip:${ip}`, '1', 'EX', block_window) + } +} + +export function has_profanity(title: string, body: string) { + const text = `${title}\n${body}`.toLowerCase() + return profanity_filter.check(text) +} diff --git a/src/routes/api/issue/+server.ts b/src/routes/api/issue/+server.ts new file mode 100644 index 00000000..f17db0cc --- /dev/null +++ b/src/routes/api/issue/+server.ts @@ -0,0 +1,120 @@ +import { json } from '@sveltejs/kit' +import { App } from '@octokit/app' +import { GITHUB_PRIVATE_KEY } from '$env/static/private' + +import { + BODY_MAX_LENGTH, + GITHUB_APP_ID, + GITHUB_INSTALLATION_ID, + GITHUB_OWNER, + GITHUB_REPO, + TITLE_MAX_LENGTH, + NAME_MAX_LENGTH, + ORIGIN, +} from './config' +import { flag_violation, is_blocked, has_profanity, rate_limit } from '$lib/server/redis' + +const app = new App({ + appId: GITHUB_APP_ID, + privateKey: GITHUB_PRIVATE_KEY, +}) + +export const POST = async (event) => { + const ip = event.getClientAddress() + + if (await is_blocked(ip)) { + return json({ error: 'Forbidden' }, { status: 403 }) + } + + if (!(await rate_limit(ip))) { + await flag_violation(ip, 'rate_limit') + return json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 }, + ) + } + + const data = await parse_data(event.request) + + if ('error' in data) return json({ error: data.error }, { status: 400 }) + + const { title, body, url, name } = data + + if (has_profanity(title, body)) { + await flag_violation(ip, 'profanity') + return json({ error: 'Profanity detected' }, { status: 400 }) + } + + const footer = name + ? `This issue has been created by **${name}** via the submission form on ${url}` + : `This issue has been created via the submission form on ${url}` + + const full_body = `${body}\n\n---\n${footer}` + + try { + const octokit = await app.getInstallationOctokit(Number(GITHUB_INSTALLATION_ID)) + + const issue = await octokit.request('POST /repos/{owner}/{repo}/issues', { + owner: GITHUB_OWNER, + repo: GITHUB_REPO, + title, + body: full_body, + }) + + return json({ url: issue.data.html_url }) + } catch (err) { + console.error(err) + return json({ error: 'Issue could not be created' }, { status: 502 }) + } +} + +async function parse_data( + request: Request, +): Promise< + { error: string } | { title: string; body: string; url: string; name: string } +> { + const content_type = request.headers.get('Content-Type') + if (content_type !== 'application/json') { + return { error: 'Forbidden' } + } + + const origin = request.headers.get('origin') ?? '' + if (!request.url.startsWith(origin)) { + return { error: 'Forbidden' } + } + + let data + try { + data = await request.json() + } catch (_) { + return { error: 'Invalid request body' } + } + + const { title, body, url, name = '' } = data + + if (!title) return { error: 'Title required' } + if (!body) return { error: 'Body required' } + if (!url) return { error: 'URL required' } + + if (typeof title !== 'string') return { error: 'Title must be a string' } + if (typeof body !== 'string') return { error: 'Body must be a string' } + if (typeof url !== 'string') return { error: 'URL must be a string' } + if (typeof name !== 'string') return { error: 'Name must be a string' } + + if (title.length > TITLE_MAX_LENGTH) { + return { error: `Title must have at most ${TITLE_MAX_LENGTH} characters` } + } + if (body.length > BODY_MAX_LENGTH) { + return { error: `Body must have at most ${BODY_MAX_LENGTH} characters` } + } + + if (!url.startsWith('https://') && !url.startsWith('http://')) { + return { error: 'URL must be a valid URL' } + } + + if (name.length > NAME_MAX_LENGTH) { + return { error: `Name must have at most ${NAME_MAX_LENGTH} characters` } + } + + return { title, body, url, name } +} diff --git a/src/routes/api/issue/config.ts b/src/routes/api/issue/config.ts new file mode 100644 index 00000000..8b12e99f --- /dev/null +++ b/src/routes/api/issue/config.ts @@ -0,0 +1,8 @@ +export const GITHUB_APP_ID = '3330448' +export const GITHUB_INSTALLATION_ID = '122747163' +export const GITHUB_OWNER = 'ScriptRaccoon' +export const GITHUB_REPO = 'CatDat' +export const TITLE_MAX_LENGTH = 50 +export const BODY_MAX_LENGTH = 10000 +export const NAME_MAX_LENGTH = 50 +export const ORIGIN = 'https://catdat.app' diff --git a/src/routes/app.css b/src/routes/app.css index 0b271098..c65d8be4 100644 --- a/src/routes/app.css +++ b/src/routes/app.css @@ -124,15 +124,21 @@ ul li::marker { button, input, -select { +select, +textarea { font: inherit; background: none; border: none; color: inherit; } +textarea { + resize: vertical; +} + input[type='text'], input[type='search'], +textarea, select { padding: 0.25rem 0.75rem; border-radius: 0.4rem; @@ -158,6 +164,12 @@ input[type='search']::-ms-clear { display: none; } +label { + display: block; + margin-bottom: 0.1rem; + font-size: 1rem; +} + button:not(:disabled) { cursor: pointer; } @@ -190,6 +202,10 @@ button:focus-visible { white-space: nowrap; } +.full-width { + width: 100%; +} + summary { cursor: pointer; width: fit-content; diff --git a/src/routes/categories/+page.svelte b/src/routes/categories/+page.svelte index 532bb3c5..01d01778 100644 --- a/src/routes/categories/+page.svelte +++ b/src/routes/categories/+page.svelte @@ -4,6 +4,7 @@ import ChipGroup from '$components/ChipGroup.svelte' import MetaData from '$components/MetaData.svelte' import SearchFilter from '$components/SearchFilter.svelte' + import SuggestionForm from '$components/SuggestionForm.svelte' import { filter_by_tag, pluralize } from '$lib/client/utils' let { data } = $props() @@ -46,6 +47,8 @@ + +