Compare commits

...

9 Commits

Author SHA1 Message Date
286735b8a5 Merge pull request 'dev' (#2) from dev into production
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 0s
Reviewed-on: #2
2025-03-17 16:03:35 +00:00
3843be7675 docker files and deployment script
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 0s
2025-03-17 17:02:20 +01:00
b171956105 added google auth provider
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 0s
2025-03-16 14:34:32 +01:00
c6cfecdb0f fixed demo workflow
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 0s
2025-03-15 23:50:02 +01:00
1597d4f113 fixed editor missing peaces
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 0s
2025-03-15 23:48:44 +01:00
d9ed115e56 fixed demo workflow
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 8s
2025-03-15 21:20:13 +01:00
23a530d1fb changed root layout metadata
Some checks are pending
Gitea Actions Demo / Explore-Gitea-Actions (push) Waiting to run
2025-03-15 21:18:24 +01:00
ae48931a87 added test action
Some checks are pending
Gitea Actions Demo / Explore-Gitea-Actions (push) Waiting to run
2025-03-15 21:16:55 +01:00
324451e082 fixed build performance issues; optimized queries 2025-03-15 20:31:38 +01:00
62 changed files with 1174 additions and 365 deletions

10
.gitea/workflows/demo.yml Normal file
View File

@ -0,0 +1,10 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [push]
jobs:
Explore-Gitea-Actions:
runs-on: linux
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🎉 Test job completed! 🚀"

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:18-alpine
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
CMD ["pnpm", "start"]

15
deploy.sh Normal file
View File

@ -0,0 +1,15 @@
#!/bin/bash
set -e
if [ -z "$1" ]; then
echo "Usage: $0 <project_path>"
exit 1
fi
PROJECT_PATH=$1
echo "Deploying project..."
cd "$PROJECT_PATH" || { echo "Directory not found: $PROJECT_PATH"; exit 1; }
git pull origin main
docker compose up -d --no-deps --build nextapp
echo "Deployment finished!"

42
docker-compose.yml Normal file
View File

@ -0,0 +1,42 @@
version: "3.8"
services:
nextapp:
build: .
deploy:
update_config:
parallelism: 1
delay: 5s
order: start-first
container_name: logipedia
restart: always
labels:
- "traefik.enable=true"
- "traefik.http.routers.logipedia.rule=Host(`logipedia.shortman.me`)"
- "traefik.http.routers.logipedia.entrypoints=websecure"
- "traefik.http.services.logipedia.loadbalancer.server.port=3000"
- "traefik.http.routers.logipedia.tls.certresolver=myresolver"
expose:
- "3000"
# ports:
# - "3000:3000"
networks:
- webproxy
db:
image: postgres:latest
container_name: logipedia-db
restart: always
shm_size: 128mb
env_file:
- .env
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pgdata:
driver: local
networks:
webproxy:
external: true

View File

@ -6,6 +6,9 @@ import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {
eslint: {
ignoreDuringBuilds: true,
},
experimental: {
serverActions: {
bodySizeLimit: "2mb",

View File

@ -1,5 +1,5 @@
{
"name": "wiki-antifa",
"name": "logipedia",
"version": "0.1.0",
"private": true,
"type": "module",
@ -46,6 +46,7 @@
"@trpc/client": "^11.0.0-rc.446",
"@trpc/react-query": "^11.0.0-rc.446",
"@trpc/server": "^11.0.0-rc.446",
"argon2": "^0.41.1",
"cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -67,7 +68,9 @@
"superjson": "^2.2.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"tiptap-extension-resize-image": "^1.2.1",
"use-debounce": "^10.0.4",
"winston": "^3.17.0",
"zod": "^3.24.2"
},
"devDependencies": {

226
pnpm-lock.yaml generated
View File

@ -86,6 +86,9 @@ importers:
'@trpc/server':
specifier: ^11.0.0-rc.446
version: 11.0.0-rc.824(typescript@5.8.2)
argon2:
specifier: ^0.41.1
version: 0.41.1
cheerio:
specifier: ^1.0.0
version: 1.0.0
@ -149,9 +152,15 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.23)(typescript@5.8.2)))
tiptap-extension-resize-image:
specifier: ^1.2.1
version: 1.2.1(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/extension-image@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)))(@tiptap/pm@2.11.5)
use-debounce:
specifier: ^10.0.4
version: 10.0.4(react@18.3.1)
winston:
specifier: ^3.17.0
version: 3.17.0
zod:
specifier: ^3.24.2
version: 3.24.2
@ -255,10 +264,17 @@ packages:
'@cfcs/core@0.0.6':
resolution: {integrity: sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==}
'@colors/colors@1.6.0':
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
engines: {node: '>=0.1.90'}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@dabh/diagnostics@2.0.3':
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
'@daybrush/utils@1.13.0':
resolution: {integrity: sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==}
@ -969,6 +985,10 @@ packages:
'@paralleldrive/cuid2@2.2.2':
resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -1930,6 +1950,9 @@ packages:
'@types/react@18.3.18':
resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==}
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@ -2035,6 +2058,10 @@ packages:
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
argon2@0.41.1:
resolution: {integrity: sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==}
engines: {node: '>=16.17.0'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@ -2085,6 +2112,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@ -2202,20 +2232,32 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.3:
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color@3.2.1:
resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
colorspace@1.1.4:
resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@ -2470,6 +2512,9 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
enabled@2.0.0:
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
encoding-sniffer@0.2.0:
resolution: {integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==}
@ -2688,6 +2733,9 @@ packages:
picomatch:
optional: true
fecha@4.2.3:
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
@ -2707,6 +2755,9 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@ -2976,6 +3027,10 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
@ -3076,6 +3131,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kuler@2.0.0:
resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==}
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@ -3107,6 +3165,10 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
logform@2.7.0:
resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==}
engines: {node: '>= 12.0.0'}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@ -3305,6 +3367,14 @@ packages:
sass:
optional: true
node-addon-api@8.3.1:
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
engines: {node: ^18 || ^20 || >= 21}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@ -3359,6 +3429,9 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
one-time@1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -3737,6 +3810,10 @@ packages:
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
@ -3793,6 +3870,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-push-apply@1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'}
@ -3801,6 +3881,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@ -3892,6 +3976,9 @@ packages:
stable-hash@0.0.4:
resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==}
stack-trace@0.0.10:
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
@ -3927,6 +4014,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@ -4004,6 +4094,9 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'}
text-hex@1.0.0:
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@ -4024,6 +4117,13 @@ packages:
tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
tiptap-extension-resize-image@1.2.1:
resolution: {integrity: sha512-SLMAujDa+0LN/6Iv2HtU4Uk0BL6LMh4b/r85frpdnjFDW2i6pIOfTVG8jzJQ8T1EgYHNn2YG1U2HoVAGuwLc3Q==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/extension-image': ^2.0.0
'@tiptap/pm': ^2.0.0
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -4031,6 +4131,10 @@ packages:
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
triple-beam@1.4.1:
resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
engines: {node: '>= 14.0.0'}
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
@ -4240,6 +4344,14 @@ packages:
engines: {node: '>= 8'}
hasBin: true
winston-transport@4.9.0:
resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==}
engines: {node: '>= 12.0.0'}
winston@3.17.0:
resolution: {integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==}
engines: {node: '>= 12.0.0'}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@ -4327,10 +4439,18 @@ snapshots:
dependencies:
'@egjs/component': 3.0.5
'@colors/colors@1.6.0': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@dabh/diagnostics@2.0.3':
dependencies:
colorspace: 1.1.4
enabled: 2.0.0
kuler: 2.0.0
'@daybrush/utils@1.13.0': {}
'@drizzle-team/brocli@0.10.2': {}
@ -4785,6 +4905,8 @@ snapshots:
dependencies:
'@noble/hashes': 1.7.1
'@phc/format@1.0.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@ -5737,6 +5859,8 @@ snapshots:
'@types/prop-types': 15.7.14
csstype: 3.1.3
'@types/triple-beam@1.3.5': {}
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@ -5860,6 +5984,12 @@ snapshots:
arg@5.0.2: {}
argon2@0.41.1:
dependencies:
'@phc/format': 1.0.0
node-addon-api: 8.3.1
node-gyp-build: 4.8.4
argparse@2.0.1: {}
aria-hidden@1.2.4:
@ -5936,6 +6066,8 @@ snapshots:
async-function@1.0.0: {}
async@3.2.6: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@ -6074,17 +6206,27 @@ snapshots:
- '@types/react'
- '@types/react-dom'
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.3: {}
color-name@1.1.4: {}
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.2
optional: true
color@3.2.1:
dependencies:
color-convert: 1.9.3
color-string: 1.9.1
color@4.2.3:
dependencies:
@ -6092,6 +6234,11 @@ snapshots:
color-string: 1.9.1
optional: true
colorspace@1.1.4:
dependencies:
color: 3.2.1
text-hex: 1.0.0
comma-separated-tokens@2.0.3: {}
commander@4.1.1: {}
@ -6257,6 +6404,8 @@ snapshots:
emoji-regex@9.2.2: {}
enabled@2.0.0: {}
encoding-sniffer@0.2.0:
dependencies:
iconv-lite: 0.6.3
@ -6691,6 +6840,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
fecha@4.2.3: {}
file-entry-cache@6.0.1:
dependencies:
flat-cache: 3.2.0
@ -6712,6 +6863,8 @@ snapshots:
flatted@3.3.3: {}
fn.name@1.1.0: {}
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
@ -6919,8 +7072,7 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
is-arrayish@0.3.2:
optional: true
is-arrayish@0.3.2: {}
is-async-function@2.1.1:
dependencies:
@ -7013,6 +7165,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-stream@2.0.1: {}
is-string@1.1.1:
dependencies:
call-bound: 1.0.4
@ -7111,6 +7265,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
kuler@2.0.0: {}
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@ -7138,6 +7294,15 @@ snapshots:
lodash.merge@4.6.2: {}
logform@2.7.0:
dependencies:
'@colors/colors': 1.6.0
'@types/triple-beam': 1.3.5
fecha: 4.2.3
ms: 2.1.3
safe-stable-stringify: 2.5.0
triple-beam: 1.4.1
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@ -7460,6 +7625,10 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-addon-api@8.3.1: {}
node-gyp-build@4.8.4: {}
normalize-path@3.0.0: {}
novel@1.0.2(@tiptap/extension-code-block@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@ -7557,6 +7726,10 @@ snapshots:
dependencies:
wrappy: 1.0.2
one-time@1.0.0:
dependencies:
fn.name: 1.1.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@ -7944,6 +8117,12 @@ snapshots:
dependencies:
pify: 2.3.0
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
@ -8023,6 +8202,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.2.1: {}
safe-push-apply@1.0.0:
dependencies:
es-errors: 1.3.0
@ -8034,6 +8215,8 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
scheduler@0.23.2:
@ -8147,7 +8330,6 @@ snapshots:
simple-swizzle@0.2.2:
dependencies:
is-arrayish: 0.3.2
optional: true
sonner@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
@ -8167,6 +8349,8 @@ snapshots:
stable-hash@0.0.4: {}
stack-trace@0.0.10: {}
streamsearch@1.1.0: {}
string-width@4.2.3:
@ -8231,6 +8415,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@ -8322,6 +8510,8 @@ snapshots:
tapable@2.2.1: {}
text-hex@1.0.0: {}
text-table@0.2.0: {}
thenify-all@1.6.0:
@ -8343,12 +8533,20 @@ snapshots:
tiptap-extension-global-drag-handle@0.1.18: {}
tiptap-extension-resize-image@1.2.1(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/extension-image@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)))(@tiptap/pm@2.11.5):
dependencies:
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
'@tiptap/extension-image': 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))
'@tiptap/pm': 2.11.5
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
trim-lines@3.0.1: {}
triple-beam@1.4.1: {}
trough@2.2.0: {}
ts-api-utils@2.0.1(typescript@5.8.2):
@ -8600,6 +8798,26 @@ snapshots:
dependencies:
isexe: 2.0.0
winston-transport@4.9.0:
dependencies:
logform: 2.7.0
readable-stream: 3.6.2
triple-beam: 1.4.1
winston@3.17.0:
dependencies:
'@colors/colors': 1.6.0
'@dabh/diagnostics': 2.0.3
async: 3.2.6
is-stream: 2.0.1
logform: 2.7.0
one-time: 1.0.0
readable-stream: 3.6.2
safe-stable-stringify: 2.5.0
stack-trace: 0.0.10
triple-beam: 1.4.1
winston-transport: 4.9.0
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:

1
public/placeholder.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

View File

@ -1,8 +1,9 @@
import "dotenv/config";
import { env } from "@/env";
import { db } from "../src/server/db";
import { articles, categories, users } from "../src/server/db/schema";
async function seed() {
async function developmentSeed() {
const usersData = Array.from({ length: 100 }).map((_, i) => ({
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
@ -37,9 +38,29 @@ async function seed() {
console.log("Seeded " + a.length + " articles");
}
async function productionSeed() {
const user = await db.query.users.findFirst();
if (user) {
console.log("Skipped seeding, user already exists");
return;
}
const initialUser = {
name: "Admin",
email: "payblot@gmail.com",
role: 7,
};
await db.insert(users).values(initialUser);
console.log("Seeded user");
}
async function init() {
try {
await seed();
if (env.NODE_ENV === "development") await developmentSeed();
else if (env.NODE_ENV === "production") await productionSeed();
else
console.log(
"Skipped seeding, NODE_ENV is not set to development or production",
);
} catch (error) {
console.error(error);
} finally {

View File

@ -1,5 +1,6 @@
import RenderArticle from "@/components/article/render-article";
import BreadNavigator from "@/components/bread-navigator";
import RenderContent from "@/components/editor/render-content";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { appRoutes } from "@/config";
import { hasPermission, Role } from "@/lib/validation/permissions";
@ -21,8 +22,9 @@ async function Page({ params }: { params: Promise<{ slug: string }> }) {
return (
<div className="space-y-2">
<div className="flex w-full items-center justify-between">
<div className="w-full">
<div className="flex w-full items-center gap-4">
<BreadNavigator
className="w-full"
links={[
...(article?.category
? [
@ -38,7 +40,14 @@ async function Page({ params }: { params: Promise<{ slug: string }> }) {
/>
</div>
{isEditor && (
<div className="flex w-full justify-end space-x-2">
<div className="flex w-full items-center justify-end space-x-2">
<Badge
className="size-max"
variant={article.published ? "outline" : "destructive"}
>
{article.published ? "Veröffentlicht" : "Draft"}
</Badge>
<Button asChild variant={"outline"}>
<Link href={appRoutes.editArticle(article.slug)}>
<Edit className="size-4" />
@ -52,10 +61,7 @@ async function Page({ params }: { params: Promise<{ slug: string }> }) {
)}
</div>
<h1 className="text-4xl font-bold">{article.title}</h1>
{article?.content?.length ? (
<RenderArticle content={article.content} />
) : null}
{article.content && <RenderContent content={article.content} />}
</div>
);
}

View File

@ -14,21 +14,21 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { userProfileSchema } from "@/lib/validation/zod/user";
import { userSchema } from "@/lib/validation/zod/user";
import { User } from "next-auth";
import { updateUserProfile } from "@/server/actions/user";
import { toast } from "sonner";
function UserForm({ server_user, cb }: { server_user: User; cb?: () => void }) {
const form = useForm<z.infer<typeof userProfileSchema>>({
resolver: zodResolver(userProfileSchema),
const form = useForm<z.infer<typeof userSchema>>({
resolver: zodResolver(userSchema),
defaultValues: {
name: server_user?.name ?? "",
},
});
// 2. Define a submit handler.
async function onSubmit(values: z.infer<typeof userProfileSchema>) {
async function onSubmit(values: z.infer<typeof userSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
const { success } = await updateUserProfile(values);

View File

@ -10,8 +10,8 @@ import { Button } from "@/components/ui/button";
import Link from "next/link";
export default async function Home() {
const categories = await api.category.getAll({ limit: 6 });
const articles = await api.article.getAll({ limit: 6 });
const categories = await api.category.getMany({ limit: 6 });
const articles = await api.article.getMany({ limit: 6 });
return (
<>
<Alert>

View File

@ -1,8 +1,7 @@
import * as cheerio from "cheerio";
import { NextApiRequest } from "next";
import { NextResponse } from "next/server";
export async function GET(req: NextApiRequest) {
export async function GET(req: Request) {
if (req.method !== "GET") {
return NextResponse.json({ error: "Method not allowed" });
}

View File

@ -6,10 +6,11 @@ import { type Metadata } from "next";
import { TRPCReactProvider } from "@/trpc/react";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/theme-provider";
import { appConfig } from "@/config";
export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
title: appConfig.name,
description: appConfig.description,
icons: [{ rel: "icon", url: "/favicon.ico" }],
};

View File

@ -13,14 +13,17 @@ import {
} from "@/components/ui/card";
import Avatar from "../avatar";
import { Icons } from "../icons";
import { Badge } from "../ui/badge";
function ArticleCard({
title,
slug,
author,
published,
createdAt,
}: Pick<Article, "title" | "slug" | "createdAt" | "author">) {
}: Pick<Article, "title" | "slug" | "createdAt" | "author" | "published">) {
const authorName = author?.name ?? `${appConfig.name} Team`;
return (
<Link href={appRoutes.article(slug)}>
<Card className="group flex h-full flex-col justify-between">
@ -37,11 +40,21 @@ function ArticleCard({
)}
<span className="text-sm text-muted-foreground">{authorName}</span>
</div>
<p className="text-sm text-muted-foreground">
{createdAt.toLocaleDateString("de-DE", {
dateStyle: "long",
})}
</p>
<div className="flex items-center space-x-2">
{typeof published === "boolean" && (
<Badge
className="size-max px-2 py-px text-xs"
variant={published ? "outline" : "destructive"}
>
{published ? "Veröffentlicht" : "Draft"}
</Badge>
)}
<p className="text-sm text-muted-foreground">
{createdAt.toLocaleDateString("de-DE", {
dateStyle: "long",
})}
</p>
</div>
</CardFooter>
</Card>
</Link>

View File

@ -85,6 +85,7 @@ function ArticleFilterBar({
<CategorySelect
className="w-full"
initialValue={filter.category}
onSelect={(category) => {
onFilterChange({
category: category?.length ? category : undefined,

View File

@ -25,7 +25,9 @@ import CategorySelect from "@/components/category/category-select";
import { CheckCircle, XCircle } from "lucide-react";
import PublishArticleAlertDialog from "./publish-article-alert-dialog";
import { Label } from "@/components/ui/label";
import Editor from "../editor";
import dynamic from "next/dynamic";
import { appRoutes } from "@/config";
const Editor = dynamic(() => import("../editor"), { ssr: false });
export default ({ server_article }: { server_article: Article }) => {
const [loading, setLoading] = React.useState(false);
@ -77,7 +79,7 @@ export default ({ server_article }: { server_article: Article }) => {
<FormControl>
<TextareaAutosize
cols={1}
className="h-max w-full resize-none text-4xl font-bold focus-visible:outline-none"
className="h-max w-full resize-none bg-transparent text-4xl font-bold focus-visible:outline-none"
value={field.value}
onChange={(e) => {
field.onChange(e);
@ -116,7 +118,7 @@ export default ({ server_article }: { server_article: Article }) => {
// loading && "border-t-blue-600",
)}
>
<div className="relative w-full space-y-4 overflow-hidden rounded-md border-t-2 bg-muted p-4">
<div className="relative w-full space-y-4 overflow-hidden rounded-md border bg-background p-4">
<div
className={cn(
"saving absolute left-0 right-0 top-0 h-0.5 rounded-full bg-primary",
@ -151,11 +153,11 @@ export default ({ server_article }: { server_article: Article }) => {
</span>
</div>
<Link
href={"/editoren-hilfe"}
href={appRoutes.article(server_article.slug)}
target="_blank"
className="size-max scale-90 p-0 text-xs text-muted-foreground"
>
<span>? Hilfe</span>
<span>Ansehen</span>
</Link>
</div>
<FormField
@ -187,6 +189,7 @@ export default ({ server_article }: { server_article: Article }) => {
<FormItem className="w-full">
<FormControl>
<CategorySelect
className="w-full"
initialValue={field.value}
onSelect={(categoryId) => {
field.onChange(categoryId);

View File

@ -7,6 +7,7 @@ import ArticleCard from "../article-card";
import { useInfiniteItemsObserver } from "@/lib/hooks/infinite-items-observer-hook";
import { Skeleton } from "@/components/ui/skeleton";
import ArticleFilterBar, { ArticleFilter } from "../article-filter-bar";
import { Article } from "@/server/db/schema";
function InfiniteArticlesGrid() {
const [filter, setFilter] = React.useState<ArticleFilter | undefined>(
@ -19,6 +20,9 @@ function InfiniteArticlesGrid() {
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
// staleTime: 60 * 4 * 1000, // 4 minutes stale time
refetchOnMount: false, // Prevents unnecessary refetching
refetchOnWindowFocus: false, // Avoids refetch when switching tabs
},
);
// Calculate all visible items across all loaded pages
@ -43,7 +47,7 @@ function InfiniteArticlesGrid() {
{data?.pages?.length
? allItems.map((article, idx) => (
<li key={`article-${idx}`}>
<ArticleCard {...article} />
<ArticleCard {...(article as Article)} />
</li>
))
: null}

View File

@ -1,7 +0,0 @@
import React from "react";
function RenderArticle({ content }: { content: string }) {
return "render article: in work"; //<Editor readOnly editorProviderProps={{ content: content }} />;
}
export default RenderArticle;

View File

@ -5,7 +5,7 @@ import { Combobox, ComboboxProps } from "../combobox";
import { Icons } from "../icons";
function CategorySelect(props: Partial<ComboboxProps>) {
const { data: categories } = api.category.getAll.useQuery();
const { data: categories } = api.category.getMany.useQuery();
return (
<Combobox
{...(props as ComboboxProps)}
@ -16,7 +16,8 @@ function CategorySelect(props: Partial<ComboboxProps>) {
empty: "Keine Kategorien gefunden",
}}
data={
categories?.map(({ name, id }) => ({ label: name!, value: id! })) ?? []
categories?.map(({ name, slug }) => ({ label: name, value: slug })) ??
[]
}
/>
);

View File

@ -9,8 +9,8 @@ export const CATEGORY_GRID_CLASS =
function CategoryGrid({ categories }: { categories: Category[] }) {
return (
<menu className={CATEGORY_GRID_CLASS}>
{categories.map((category) => (
<li key={category.id}>
{categories.map((category, idx) => (
<li key={idx}>
<CategoryCard {...category} />
</li>
))}

View File

@ -18,6 +18,9 @@ export default function InfiniteCategoryGrid() {
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 60 * 4 * 1000, // 4 minutes stale time
refetchOnMount: false, // Prevents unnecessary refetching
refetchOnWindowFocus: false, // Avoids refetch when switching tabs
},
);
// Calculate all visible items across all loaded pages

View File

@ -1,20 +0,0 @@
"use client";
import ColorPicker, { type ColorPickerProps } from ".";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
export default function ColorPickerPopover(props: ColorPickerProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button style={{ backgroundColor: props.initialColor }} className="">
color
</Button>
</PopoverTrigger>
<PopoverContent>
<ColorPicker {...props} />
</PopoverContent>
</Popover>
);
}

View File

@ -1,16 +0,0 @@
.color-picker .react-colorful {
width: 100%;
height: 240px;
}
.color-picker .react-colorful__saturation {
border-radius: 4px 4px 0 0;
}
.color-picker .react-colorful__hue {
height: 40px;
border-radius: 0 0 4px 4px;
}
.color-picker .react-colorful__hue-pointer {
width: 12px;
height: inherit;
border-radius: 0;
}

View File

@ -1,103 +0,0 @@
"use client";
import "./color-picker.css";
import React from "react";
import { useState, useEffect } from "react";
import { HexColorPicker } from "react-colorful";
import { Input } from "../ui/input";
import { debounce } from "@/lib/utils";
const STORAGE_KEY = "savedColors";
const MAX_COLORS = 12;
const SELECT_DEBOUNCE = 500;
export type ColorPickerProps = {
onInput?: (color: string) => void;
initialColor?: string;
};
function ColorPicker({ onInput, initialColor }: ColorPickerProps) {
const [mounted, setMounted] = useState(false);
const [customColors, setCustomColors] = useState<string[]>([]);
const [color, setColor] = useState(initialColor ?? "#ff0000");
// Load colors from localStorage on mount
useEffect(() => {
if (initialColor?.length) setColor(initialColor);
const storageColors = localStorage.getItem(STORAGE_KEY);
const storedColors = storageColors ? JSON.parse(storageColors) : [];
if (storedColors.length) {
setCustomColors(storedColors);
if (!initialColor?.length) setColor(storedColors[0]);
setMounted(true);
}
}, []);
const persistColor = (newColor: string) => {
if (newColor.length < 2) return;
if (customColors[0] === newColor) return; // Prevent duplicate consecutive colors
const updatedColors = [
newColor,
...customColors.filter((c) => c !== newColor),
].slice(0, MAX_COLORS);
console.log(updatedColors);
setCustomColors(updatedColors);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedColors));
};
const selectColor = (newColor: string) => {
persistColor(newColor);
onInput?.(newColor);
};
const selectColorDebounced = debounce((newColor: string) => {
selectColor(newColor);
}, SELECT_DEBOUNCE);
const handleColorChange = (newColor: string, skipDebounce = false) => {
setColor(newColor);
if (skipDebounce)
selectColor(newColor); // Delayed save
else selectColorDebounced(newColor);
};
return (
<div className="color-picker flex flex-col items-center gap-4">
{/* React Colorful Picker */}
<HexColorPicker
color={color}
onChange={handleColorChange}
className="w-full"
/>
{/* Default Input (Native Color Picker) */}
<div className="flex w-full gap-2">
<div
className="size-8 rounded-md border"
style={{ backgroundColor: color }}
/>
<Input
className="h-8"
value={color}
onInput={(e) => handleColorChange(e.currentTarget.value)}
/>
</div>
{/* Display Recent Colors */}
<div className="flex flex-wrap gap-2">
{customColors.map((col) => (
<button
key={col}
className="size-8 rounded border"
style={{ backgroundColor: col }}
onClick={() => handleColorChange(col, true)}
/>
))}
</div>
</div>
);
}
export default ColorPicker;

View File

@ -50,6 +50,7 @@ export function Combobox({
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(initialValue ?? "");
const selectedItem = data.find((item) => item.value === initialValue)!;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>

View File

@ -36,7 +36,6 @@ export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
"use no memo";
const [pagination, setPagination] = React.useState<PaginationState>({
pageSize: 25,
pageIndex: 0,

View File

@ -18,15 +18,15 @@ import {
Youtube,
} from "novel";
import LinkPreview from "./link-preview";
import { Heading } from "@tiptap/extension-heading";
import { cx } from "class-variance-authority";
import { slashCommand } from "./slash-commands";
import ImageResize from "tiptap-extension-resize-image";
const placeholder = Placeholder;
const tiptapLink = TiptapLink.configure({
HTMLAttributes: {
class: cx(
"text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
"underline text-foreground underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
),
},
});
@ -46,6 +46,12 @@ const tiptapImage = TiptapImage.extend({
},
});
const resizeImage = ImageResize.configure({
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
});
const updatedImage = UpdatedImage.configure({
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
@ -108,6 +114,7 @@ export const defaultExtensions = [
tiptapLink,
tiptapImage,
updatedImage,
resizeImage,
taskList,
taskItem,
horizontalRule,

View File

@ -28,7 +28,7 @@ const InputLink = ({ onSubmit }: { onSubmit: (link: string) => void }) => {
inputRef.current?.focus();
}, []);
return (
<form onSubmit={handleSubmit} className="flex gap-2 " autoFocus>
<form onSubmit={handleSubmit} className="flex gap-2" autoFocus>
<Input
ref={inputRef}
autoFocus
@ -36,7 +36,7 @@ const InputLink = ({ onSubmit }: { onSubmit: (link: string) => void }) => {
placeholder="Enter a link"
value={link}
onChange={(e) => setLink(e.target.value)}
className="flex-1 focus-visible:ring-transparent border-0"
className="flex-1 border-0 focus-visible:ring-transparent"
/>
<Button
type="submit"
@ -60,29 +60,31 @@ const Preview = ({
_description?.length > 100
? `${_description?.slice(0, 150)}...`
: _description;
return (
<a href={href} target="_blank">
<div className=" flex gap-4 flex-col-reverse md:flex-row">
<div className="w-full space-y-2">
<h2
className="text-xl"
style={{
margin: 0,
fontSize: "1.5rem",
}}
>
{title}
</h2>
<p className="text-sm mt-2">{description}</p>
<span className="text-xs text-muted-foreground ">{href}</span>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<div className="flex w-full flex-col justify-between">
<div className="w-full space-y-2">
<h2
className="text-xl"
style={{
margin: 0,
fontSize: "1.5rem",
}}
>
{title}
</h2>
<p className="mt-2 text-sm">{description}</p>
</div>
<span className="text-xs text-muted-foreground">{href}</span>
</div>
{image?.length ? (
<img
src={image}
alt={title}
className="w-full max-w-40 rounded-md object-cover "
className="w-full max-w-40 rounded-md object-cover"
/>
) : (
<div className="size-20 rounded-md bg-muted" />
@ -98,13 +100,13 @@ export const LinkPreviewComponent: React.FC<NodeViewProps> = ({
extension,
}) => {
const [preview, setPreview] = React.useState<LinkPreviewData | undefined>(
(node.attrs?.href?.length && (node.attrs as LinkPreviewData)) ?? undefined
(node.attrs?.href?.length && (node.attrs as LinkPreviewData)) ?? undefined,
);
const [loading, setLoading] = React.useState(false);
return (
<NodeViewWrapper as="div" className="p-4 rounded-md bg-background border ">
<NodeViewWrapper as="div" className="rounded-md border bg-background p-4">
{loading ? (
<Skeleton className="h-8 w-full rounded" />
) : preview ? (

View File

@ -14,6 +14,7 @@ import {
import { Command, renderItems, createSuggestionItems } from "novel";
import { selectionItems } from "../../selector/selection-items";
import { uploadFile } from "@/server/actions/image";
// const items = selectionItems.filter((item) => !item.inline);
// const defaultSuggestionItems = items.map((item) => (
@ -148,10 +149,19 @@ export const suggestionItems = createSuggestionItems([
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
if (input.files?.[0]) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
// uploadFn(file, editor.view, pos);
const formData = new FormData();
formData.append("file", file);
const url = await uploadFile(formData);
console.log("URL", url);
if (!url) return;
editor
.chain()
.focus()
.setImage({ src: url, alt: file.name, title: file.name })
.run();
}
};
input.click();

View File

@ -14,23 +14,26 @@ import { MenuBar } from "./menu/menu-bar";
const Editor = ({
onContentChange,
initialContent,
readOnly,
}: {
initialContent: JSONContent | null;
onContentChange: (content: JSONContent) => void;
onContentChange?: (content: JSONContent) => void;
readOnly?: boolean;
}) => {
return (
<EditorRoot>
<EditorContent
slotBefore={<MenuBar />}
slotBefore={!readOnly && <MenuBar />}
extensions={defaultExtensions}
editorProps={{
handleDOMEvents: {
keydown: (_view, event) => handleCommandNavigation(event),
},
}}
editable={!readOnly}
initialContent={initialContent ?? { type: "doc" }}
onUpdate={({ editor }) => {
onContentChange(editor.getJSON());
onContentChange?.(editor.getJSON());
}}
>
<SlashCommandComponent />

View File

@ -0,0 +1,11 @@
"use client";
import dynamic from "next/dynamic";
import { JSONContent } from "novel";
import React from "react";
const Editor = dynamic(() => import("."), { ssr: false });
function RenderContent({ content }: { content: JSONContent }) {
return <Editor readOnly initialContent={content} />;
}
export default RenderContent;

View File

@ -15,80 +15,43 @@ export interface BubbleColorMenuItem {
const TEXT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "var(--novel-black)",
color: "var(--foreground)",
},
{
name: "Purple",
color: "#9333EA",
color: "var(--color-purple-600)",
},
{
name: "Red",
color: "#E00000",
color: "var(--color-red-600)",
},
{
name: "Yellow",
color: "#EAB308",
color: "var(--color-yellow-600)",
},
{
name: "Blue",
color: "#2563EB",
color: "var(--color-blue-600)",
},
{
name: "Green",
color: "#008A00",
color: "var(--color-emerald-600)",
},
{
name: "Orange",
color: "#FFA500",
color: "var(--color-orange-600)",
},
{
name: "Pink",
color: "#BA4081",
color: "var(--color-pink-600)",
},
{
name: "Gray",
color: "#A8A29E",
color: "var(--color-gray-600)",
},
];
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "var(--novel-highlight-default)",
},
{
name: "Purple",
color: "var(--novel-highlight-purple)",
},
{
name: "Red",
color: "var(--novel-highlight-red)",
},
{
name: "Yellow",
color: "var(--novel-highlight-yellow)",
},
{
name: "Blue",
color: "var(--novel-highlight-blue)",
},
{
name: "Green",
color: "var(--novel-highlight-green)",
},
{
name: "Orange",
color: "var(--novel-highlight-orange)",
},
{
name: "Pink",
color: "var(--novel-highlight-pink)",
},
{
name: "Gray",
color: "var(--novel-highlight-gray)",
},
];
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = TEXT_COLORS;
interface ColorSelectorProps {
open: boolean;
@ -100,11 +63,11 @@ export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
if (!editor) return null;
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color })
editor.isActive("textStyle", { color }),
);
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color })
editor.isActive("highlight", { color }),
);
return (
@ -126,7 +89,7 @@ export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
<PopoverContent
sideOffset={5}
className="my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl "
className="my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl"
align="start"
>
<div className="flex flex-col">

View File

@ -9,12 +9,11 @@ import {
Heading3,
Heading4,
ListOrdered,
TextIcon,
TextQuote,
SeparatorHorizontalIcon,
Heading5,
Heading6,
QuoteIcon,
TypeIcon,
} from "lucide-react";
import { EditorInstance } from "novel";
@ -64,7 +63,7 @@ export const selectionItems: SelectorItem[] = [
// blocks
{
name: "Text",
icon: TextIcon,
icon: TypeIcon,
command: (editor) => editor.chain().focus().clearNodes().run(),
isActive: (editor) => editor.isActive("paragraph"), //node?.type?.name === "paragraph",
},

View File

@ -8,9 +8,6 @@ const items = selectionItems.filter((item) => item.inline);
export const TextButtons = () => {
const { editor } = useEditor();
if (!editor) return null;
editor.on("selectionUpdate", () => {
editor.view.dispatch(editor.state.tr);
});
return (
<div className="flex">

View File

@ -72,36 +72,40 @@
}
.tiptap hr {
@apply border-border;
/* border-color: var(--border); */
margin: 0.25rem 0;
}
::selection {
background-color: #5abbf7;
border-radius: 0.375rem;
}
.menu-bar button {
transition-duration: 0s;
}
.menu-bar .is-active {
border-color: var(--primary-color);
img {
@apply rounded-md;
}
/* Task List */
ul[data-type="taskList"] li {
@apply m-0 my-3 flex items-center;
}
ul[data-type="taskList"] li p {
@apply m-0 my-0 flex items-center;
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
@apply size-4 border border-border bg-muted;
-webkit-appearance: none;
appearance: none;
margin: 0;
cursor: pointer;
width: 1.2em;
height: 1.2em;
position: relative;
top: 5px;
border: 2px solid var(--border);
margin-right: 0.3rem;
display: grid;
border-radius: 0.25rem;
place-content: center;
@ -126,7 +130,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:checked::before {
transform: scale(1);
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: var(--muted-foreground);
@apply text-foreground/75;
text-decoration: line-through;
text-decoration-thickness: 2px;
}

View File

@ -1,5 +1,6 @@
export type AppConfig = {
name: string;
description: string;
socials: {
discord: string;
};
@ -7,6 +8,7 @@ export type AppConfig = {
export const appConfig: AppConfig = {
name: "Logipedia",
description: "Logipedia ist Wissen",
socials: {
discord: "https://discord.com",

View File

@ -22,6 +22,7 @@ export type AppRoutes = {
// Auth routes
signin: string;
signup: string;
signout: string;
profile: string;
};
@ -43,6 +44,7 @@ export const appRoutes: AppRoutes = {
// auth
signin: "/api/auth/signin",
signup: "/api/auth/signin",
signout: "/api/auth/signout",
profile: "/me",
};

View File

@ -13,6 +13,8 @@ export const env = createEnv({
: z.string().optional(),
AUTH_DISCORD_ID: z.string(),
AUTH_DISCORD_SECRET: z.string(),
AUTH_GOOGLE_ID: z.string(),
AUTH_GOOGLE_SECRET: z.string(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
@ -36,6 +38,8 @@ export const env = createEnv({
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_DISCORD_ID: process.env.AUTH_DISCORD_ID,
AUTH_DISCORD_SECRET: process.env.AUTH_DISCORD_SECRET,
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
},

27
src/lib/logger.ts Normal file
View File

@ -0,0 +1,27 @@
import winston from "winston";
const customLevels = {
levels: {
error: 0,
warn: 1,
info: 2,
sql: 3,
debug: 4,
},
colors: {
error: "red",
warn: "yellow",
info: "green",
sql: "magenta",
debug: "blue",
},
};
winston.addColors(customLevels.colors);
export const logger = winston.createLogger({
levels: customLevels.levels,
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "db_queries.log", level: "sql" }),
new winston.transports.File({ filename: "errors.log", level: "error" }),
],
});

View File

@ -1,7 +1,28 @@
import { z } from "zod";
export const userProfileSchema = z.object({
export const userSchema = z.object({
name: z.string().min(1),
// image: z.string().optional(),
// email: z.string().email(),
email: z.string().email(),
image: z.string().optional(),
});
export const passwordSchema = z.string().min(8, {
message: "Passwort muss mindestens 8 Zeichen lang sein",
});
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const registerSchema = z
.object({
name: z.string().min(1),
email: z.string().email(),
password: passwordSchema,
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwörter stimmen nicht überein",
path: ["confirmPassword"],
});

View File

@ -28,6 +28,8 @@ export async function updateArticle(
article: { ...article, content: JSON.parse(article.content) },
articleId,
});
revalidatePath("/");
// if (!result[0]?.id?.length) return false;
// return revalidatePath(`/artikel/${result[0]?.slug}/edit`);
}
@ -35,5 +37,7 @@ export async function deleteArticle(articleId: string) {
const result = await api.article.delete({
articleId,
});
revalidatePath("/");
// if (!result[0]?.id?.length) return false;
}

View File

@ -0,0 +1,7 @@
"use server";
import { signIn } from "@/server/auth";
export async function loginOAuth(provider: string) {
return await signIn(provider);
}

View File

@ -1,17 +1,30 @@
"use server";
import fs from "node:fs/promises";
import { revalidatePath } from "next/cache";
import { auth } from "../auth";
import { hasPermission, Role } from "@/lib/validation/permissions";
export async function uploadFile(formData: FormData) {
const session = await auth();
if (!session || !hasPermission(session.user.role, Role.EDITOR)) return false;
console.log("Starting upload");
const file = formData.get("file") as File;
const arrayBuffer = await file.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer);
const filename = `upload-${Date.now()}-${file.name}.png`;
console.log("Filename", filename);
try {
await fs.writeFile(`./public/uploads/${filename}`, buffer);
console.log("File uploaded successfully");
revalidatePath("/");
return `/uploads/${filename}`;
} catch (e) {
console.error("Error uploading file:", e);
return false;
}
}

View File

@ -1,13 +1,11 @@
"use server";
import { userProfileSchema } from "@/lib/validation/zod/user";
import { userSchema } from "@/lib/validation/zod/user";
import { api } from "@/trpc/server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
export async function updateUserProfile(
profile: z.infer<typeof userProfileSchema>,
) {
export async function updateUserProfile(profile: z.infer<typeof userSchema>) {
const [result] = await api.users.updateProfile({ profile });
if (!result?.id) return { success: false };
revalidatePath("/me");

View File

@ -4,6 +4,7 @@ import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { usersRouter } from "./routers/users";
import { authorRouter } from "./routers/author";
import { appRouter as globalRouter } from "./routers/app";
import { authRouter } from "./routers/auth";
/**
* This is the primary router for your server.
*
@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
users: usersRouter,
author: authorRouter,
app: globalRouter,
auth: authRouter,
});
// export type definition of API

View File

@ -1,29 +1,8 @@
import { z } from "zod";
import { desc, eq } from "drizzle-orm";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { desc, ilike, like } from "drizzle-orm";
import {
articles as articlesTable,
categories as categoriesTable,
lower,
} from "@/server/db/schema";
import { articles as articlesTable } from "@/server/db/schema";
export const appRouter = createTRPCRouter({
searchContent: publicProcedure
.input(
z.object({
query: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const articles = await ctx.db.query.articles.findMany({
where: ilike(articlesTable.title, "%" + input.query + "%"),
});
const categories = await ctx.db.query.categories.findMany({
where: like(categoriesTable.name, "%" + input.query + "%"),
});
return { articles, categories };
}),
getSidebarMain: publicProcedure.query(async ({ ctx }) => {
const categories = await ctx.db.query.categories.findMany({
limit: 3,
@ -34,6 +13,8 @@ export const appRouter = createTRPCRouter({
});
const articles = await ctx.db.query.articles.findMany({
limit: 3,
where: eq(articlesTable.published, true),
orderBy: desc(articlesTable.createdAt),
columns: {
slug: true,
title: true,

View File

@ -71,10 +71,13 @@ export const articleRouter = createTRPCRouter({
get: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.articles.findFirst({
where: eq(articles.slug, input.slug),
const user = ctx?.session?.user;
const isEditor = user ? hasPermission(user.role, Role.EDITOR) : false;
const publishedArg = !isEditor ? eq(articles.published, true) : undefined;
return (await ctx.db.query.articles.findFirst({
where: and(eq(articles.slug, input.slug), publishedArg),
with: { category: true },
});
})) as Article;
}),
getByCursor: publicProcedure
.input(
@ -112,26 +115,24 @@ export const articleRouter = createTRPCRouter({
sortConfig,
cursorObj,
);
const user = ctx?.session?.user;
const isEditor = user ? hasPermission(user.role, Role.EDITOR) : false;
const publishedArg = !isEditor ? eq(articles.published, true) : undefined;
const items = await ctx.db.query.articles.findMany({
where: and(
cursorArg,
categoryArg,
queryFilterArg,
eq(articles.published, true),
),
where: and(cursorArg, categoryArg, queryFilterArg, publishedArg),
limit: limit + 1,
orderBy,
columns: {
title: true,
slug: true,
createdAt: true,
published: isEditor ? true : false,
},
});
let nextCursor: string | undefined = undefined;
if (items.length > limit) {
console.log("Configure next cursor");
const cursorItem = items.pop();
// Create a cursor object with the relevant fields for sorting
const cursorData: ArticleCursor = {
@ -150,7 +151,7 @@ export const articleRouter = createTRPCRouter({
nextCursor,
};
}),
getAll: publicProcedure
getMany: publicProcedure
.input(
z
.object({
@ -160,11 +161,12 @@ export const articleRouter = createTRPCRouter({
.optional(),
)
.query(async ({ ctx, input }) => {
const limit = input?.limit ?? 50;
return (await ctx.db.query.articles.findMany({
where: input?.categoryId
? eq(articles.categoryId, input.categoryId)
: undefined,
limit: input?.limit,
limit: limit,
columns: {
title: true,
slug: true,

View File

@ -0,0 +1,54 @@
import { passwordSchema, userSchema } from "@/lib/validation/zod/user";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { users } from "@/server/db/schema";
import argon from "argon2";
export const authRouter = createTRPCRouter({
register: publicProcedure
.input(
z.object({
user: userSchema,
password: passwordSchema,
}),
)
.mutation(async ({ ctx, input }) => {
const {
password,
user: { email, name },
} = input;
// Check if user already exists
try {
const existingUser = await ctx.db.query.users.findFirst({
where: eq(users.email, email),
});
if (existingUser) {
return { success: false, message: "User already exists" };
}
// Hash the password (12 is a good cost factor)
const hashedPassword = await argon.hash(password);
// Create user in database
const [user] = await ctx.db
.insert(users)
.values({
name,
email,
password: hashedPassword,
})
.returning({ id: users.id });
console.log(user);
if (user) {
return { success: true, message: "User created successfully" };
}
return { success: false, message: "Error creating user" };
} catch (e) {
console.error(e);
return { success: false, message: "Error creating user" };
}
}),
});

View File

@ -1,4 +1,4 @@
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const authorRouter = createTRPCRouter({
getCount: publicProcedure.query(async ({ ctx }) => {

View File

@ -12,11 +12,11 @@ import {
count,
desc,
eq,
gt,
gte,
ilike,
like,
lte,
sql,
} from "drizzle-orm";
import { hasPermission, Role } from "@/lib/validation/permissions";
import {
@ -60,13 +60,6 @@ const getCategorySorting = (sort: string, cursor?: CategoryCursor) => {
};
export const categoryRouter = createTRPCRouter({
search: publicProcedure
.input(z.object({ query: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.categories.findMany({
where: like(categories.name, "%" + input.query + "%"),
});
}),
get: publicProcedure
.input(z.object({ slug: z.string(), with: z.any() }))
.query(async ({ ctx, input }) => {
@ -76,7 +69,7 @@ export const categoryRouter = createTRPCRouter({
})) as Category;
}),
getAll: publicProcedure
getMany: publicProcedure
.input(
z
.object({
@ -85,9 +78,15 @@ export const categoryRouter = createTRPCRouter({
.optional(),
)
.query(async ({ ctx, input }) => {
return await ctx.db.query.categories.findMany({
limit: input?.limit,
});
const limit = input?.limit ?? 50;
return (await ctx.db.query.categories.findMany({
limit: limit,
columns: {
name: true,
slug: true,
createdAt: true,
},
})) as Category[];
}),
getByCursor: publicProcedure
@ -135,7 +134,6 @@ export const categoryRouter = createTRPCRouter({
let nextCursor: string | undefined = undefined;
if (items.length > limit) {
console.log("Configure next cursor");
const cursorItem = items.pop();
// Create a cursor object with the relevant fields for sorting
const cursorData: CategoryCursor = {
@ -172,6 +170,12 @@ export const categoryRouter = createTRPCRouter({
.values({ ...input.category, slug })
.returning({
slug: categories.slug,
})
.onConflictDoUpdate({
target: categories.slug,
set: {
slug: sql`${slug} || '-' || (SELECT COUNT(*) FROM ${categories} WHERE slug LIKE ${slug + "-%"})`,
},
});
}),
update: protectedProcedure

View File

@ -3,17 +3,17 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
import { z } from "zod";
import { users } from "@/server/db/schema";
import { desc, eq } from "drizzle-orm";
import { userProfileSchema } from "@/lib/validation/zod/user";
import { userSchema } from "@/lib/validation/zod/user";
export const usersRouter = createTRPCRouter({
updateProfile: protectedProcedure
.input(z.object({ profile: userProfileSchema }))
.input(z.object({ profile: userSchema }))
.mutation(async ({ ctx, input }) => {
return await ctx.db
.update(users)
.set(input.profile)
.where(eq(users.id, ctx.session.user.id))
.returning();
.returning({ id: users.id });
}),
getAll: protectedProcedure.query(async ({ ctx }) => {
@ -21,6 +21,12 @@ export const usersRouter = createTRPCRouter({
if (!isAdmin) throw new Error("You are not allowed to get all users");
return await ctx.db.query.users.findMany({
orderBy: desc(users.role),
columns: {
name: true,
email: true,
role: true,
id: true,
},
});
}),

View File

@ -89,6 +89,7 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
if (t._config.isDev) {
// artificial delay in dev
// const waitMs = 1000 * 5; // 5 seconds
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}

View File

@ -1,6 +1,7 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { type DefaultSession, type NextAuthConfig } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import GoogleProvider from "next-auth/providers/google";
import { db } from "@/server/db";
import {
@ -37,9 +38,18 @@ declare module "next-auth" {
*
* @see https://next-auth.js.org/configuration/options
*/
export const adapter = DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}) as Adapter;
export const authConfig = {
providers: [
DiscordProvider,
GoogleProvider,
/**
* ...add more providers here.
*
@ -50,19 +60,23 @@ export const authConfig = {
* @see https://next-auth.js.org/providers/github
*/
],
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}) as Adapter,
// pages: {
// signIn: appRoutes.signin, // Custom sign in page
// },
adapter,
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
},
}),
session: ({ session, user }) => {
return {
...session,
user: {
...session.user,
id: user.id,
},
};
},
},
session: {
strategy: "database",
maxAge: 60 * 60 * 24 * 7, // 7 days,
},
} satisfies NextAuthConfig;

View File

@ -0,0 +1,436 @@
import { createId } from "@paralleldrive/cuid2";
export function generateSessionToken() {
return createId();
}
export const fromDate = (time: number, date = Date.now()) => {
return new Date(date + time * 1000);
};
// CredentialsProvider({
// name: "Credentials",
// credentials: {
// email: { label: "Email", type: "email" },
// password: { label: "Password", type: "password" },
// },
// authorize: async (credentials) => {
// let user: Session["user"] | null = null;
// if (!credentials?.email || !credentials?.password) return null;
// if (
// typeof credentials.password !== "string" ||
// typeof credentials.email !== "string"
// ) {
// console.log("WARN: Password or Email is not a string.");
// return null;
// }
// try {
// // Add your own database logic here
// const response = await db.query.users.findFirst({
// where: eq(users.email, String(credentials.email)),
// });
// // No user found
// if (!response || !response.password) {
// if (!response?.password) return null;
// }
// user = response;
// // Check password - using timing-safe comparison via bcrypt
// const isValidPassword = await argon.verify(
// String(response.password),
// String(credentials?.password),
// );
// if (!isValidPassword) return null;
// console.log("User authenticated successfully:", user.id);
// return {
// name: user.name,
// email: user.email,
// image: user.image,
// id: user.id,
// role: user.role,
// } as User;
// } catch (e) {
// console.log("WARN: Error while validating credentials.");
// return null;
// }
// },
// }),
// callback
// async signIn({ user, account, profile, email, credentials }) {
// console.log("👉 SignIn callback triggered", user?.id);
// console.log("👉 SignIn callback credentials", credentials);
// if (credentials && user.id) await createSession(user.id!);
// // Return true to allow sign-in
// return true;
// },
// server action
// export async function login(values: z.infer<typeof loginSchema>) {
// return await signIn("credentials", values);
// }
// export async function register(values: z.infer<typeof registerSchema>) {
// try {
// const { success } = await api.auth.register({
// user: {
// email: values.email,
// name: values.name,
// },
// password: values.password,
// });
// await signIn("credentials", {
// email: values.email,
// password: values.password,
// });
// return success;
// } catch (e) {
// return false;
// }
// }
// export async function createSession(userId: string) {
// if (!adapter.createSession) throw new Error("Adapter not initialized");
// const sessionToken = generateSessionToken();
// const sessionExpiry = fromDate(authConfig.session.maxAge);
// console.log("👉 createSession", sessionToken);
// const session = await adapter.createSession({
// sessionToken: sessionToken,
// userId: userId,
// expires: sessionExpiry,
// });
// console.log("👉 createSession session", session);
// const cookieStore = await cookies();
// cookieStore.set("authjs.session-token", sessionToken, {
// expires: sessionExpiry,
// });
// }
// register page
// "use client";
// import { cn } from "@/lib/utils";
// import { Button } from "@/components/ui/button";
// import { Card, CardContent } from "@/components/ui/card";
// import { Input } from "@/components/ui/input";
// import { zodResolver } from "@hookform/resolvers/zod";
// import { useForm } from "react-hook-form";
// import { z } from "zod";
// import {
// Form,
// FormControl,
// FormField,
// FormItem,
// FormLabel,
// FormMessage,
// } from "@/components/ui/form";
// import { registerSchema } from "@/lib/validation/zod/user";
// import { appConfig, appRoutes } from "@/config";
// import Link from "next/link";
// import { login, register } from "@/server/actions/auth";
// import { toast } from "sonner";
// import { AuthProviderList, LeagalFooter } from ".";
// export function RegisterForm({
// className,
// ...props
// }: React.ComponentProps<"div">) {
// const form = useForm<z.infer<typeof registerSchema>>({
// resolver: zodResolver(registerSchema),
// defaultValues: {
// name: "",
// email: "",
// password: "",
// confirmPassword: "",
// },
// });
// // 2. Define a submit handler.
// async function onSubmit(values: z.infer<typeof registerSchema>) {
// const success = await register(values);
// if (!success) toast.error("Registrierung fehlgeschlagen");
// form.reset();
// }
// return (
// <div className={cn("flex flex-col gap-6", className)} {...props}>
// <Card className="overflow-hidden">
// <CardContent className="grid p-0 md:grid-cols-2">
// <Form {...form}>
// <form onSubmit={form.handleSubmit(onSubmit)} className="p-6 md:p-8">
// <div className="flex flex-col gap-6">
// <div className="flex flex-col items-center text-center">
// <h1 className="text-2xl font-bold">Wilkommen</h1>
// <p className="text-balance text-muted-foreground">
// Erstelle dein {appConfig.name} Konto
// </p>
// </div>
// <AuthProviderList />
// <div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
// <span className="relative z-10 bg-background px-2 text-muted-foreground">
// Oder mit
// </span>
// </div>
// <div className="space-y-4">
// <FormField
// control={form.control}
// name="name"
// render={({ field }) => (
// <FormItem>
// <FormLabel>Name</FormLabel>
// <FormControl>
// <Input
// placeholder="Mustermax"
// tabIndex={1}
// {...field}
// />
// </FormControl>
// <FormMessage />
// </FormItem>
// )}
// />
// <FormField
// control={form.control}
// name="email"
// render={({ field }) => (
// <FormItem>
// <FormLabel>Email</FormLabel>
// <FormControl>
// <Input
// placeholder="email@beispiel.com"
// tabIndex={2}
// {...field}
// />
// </FormControl>
// <FormMessage />
// </FormItem>
// )}
// />
// <FormField
// control={form.control}
// name="password"
// render={({ field }) => (
// <FormItem>
// <FormLabel>Passwort</FormLabel>
// <FormControl>
// <Input
// type="password"
// tabIndex={3}
// placeholder="******"
// {...field}
// />
// </FormControl>
// <FormMessage />
// </FormItem>
// )}
// />
// <FormField
// control={form.control}
// name="confirmPassword"
// render={({ field }) => (
// <FormItem>
// <FormLabel>Passwort Wiederholen</FormLabel>
// <FormControl>
// <Input
// type="password"
// tabIndex={4}
// placeholder="******"
// {...field}
// />
// </FormControl>
// <FormMessage />
// </FormItem>
// )}
// />
// </div>
// <Button type="submit">Login</Button>
// <div className="text-center text-sm">
// Du hast bereits ein Konto?{" "}
// <Link
// href={appRoutes.signin}
// className="underline underline-offset-4"
// >
// Anmelden
// </Link>
// </div>
// </div>
// </form>
// </Form>
// <div className="relative hidden bg-muted md:block">
// <img
// src="/placeholder.svg"
// alt="Image"
// className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
// />
// </div>
// </CardContent>
// </Card>
// <LeagalFooter />
// </div>
// );
// }
// Login page
// export function LoginForm({
// className,
// ...props
// }: React.ComponentProps<"div">) {
// const form = useForm<z.infer<typeof loginSchema>>({
// resolver: zodResolver(loginSchema),
// defaultValues: {
// email: "",
// password: "",
// },
// });
// // 2. Define a submit handler.
// async function onSubmit(values: z.infer<typeof loginSchema>) {
// const success = await login(values);
// // if (!success) toast.error("Login fehlgeschlagen");
// // form.reset();
// }
// return (
// <div className={cn("flex flex-col gap-6", className)} {...props}>
// <Card className="overflow-hidden">
// <CardContent className="grid p-0 md:grid-cols-2">
// <Form {...form}>
// <form onSubmit={form.handleSubmit(onSubmit)} className="p-6 md:p-8">
// <div className="flex flex-col gap-6">
// <div className="flex flex-col items-center text-center">
// <h1 className="text-2xl font-bold">Wilkommen Zurück</h1>
// <p className="text-balance text-muted-foreground">
// Melde dich in deinem {appConfig.name} Konto an
// </p>
// </div>
// <AuthProviderList />
// <div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
// <span className="relative z-10 bg-background px-2 text-muted-foreground">
// Oder mit
// </span>
// </div>
// <div className="space-y-4">
// <FormField
// control={form.control}
// name="email"
// render={({ field }) => (
// <FormItem>
// <FormLabel>Email</FormLabel>
// <FormControl>
// <Input
// placeholder="email@beispiel.com"
// tabIndex={1}
// {...field}
// />
// </FormControl>
// <FormMessage />
// </FormItem>
// )}
// />
// <FormField
// control={form.control}
// name="password"
// render={({ field }) => (
// <FormItem>
// <div className="flex items-center">
// <FormLabel>Passwort</FormLabel>
// <a
// href="#"
// className="ml-auto text-sm underline-offset-2 hover:underline"
// >
// passwort vergessen?
// </a>
// </div>
// <FormControl>
// <Input
// type="password"
// tabIndex={2}
// placeholder="******"
// {...field}
// />
// </FormControl>
// <FormMessage />
// </FormItem>
// )}
// />
// </div>
// <Button type="submit">Login</Button>
// <div className="text-center text-sm">
// Du hast noch kein Konto?{" "}
// <Link
// href={appRoutes.signup}
// className="underline underline-offset-4"
// >
// Registrieren
// </Link>
// </div>
// </div>
// </form>
// </Form>
// <div className="relative hidden bg-muted md:block">
// <img
// src="/placeholder.svg"
// alt="Image"
// className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
// />
// </div>
// </CardContent>
// </Card>
// <LeagalFooter />
// </div>
// );
// }
// export function AuthProviderList() {
// return (
// <div className="grid grid-cols-3 gap-4">
// <Button
// variant="outline"
// className="w-full"
// onClick={() => loginOAuth("discord")}
// >
// <Icons.discord />
// <span className="sr-only">Login with Discord</span>
// </Button>
// <Button variant="outline" className="w-full">
// <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
// <path
// d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
// fill="currentColor"
// />
// </svg>
// <span className="sr-only">Login with Google</span>
// </Button>
// <Button variant="outline" className="w-full">
// <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
// <path
// d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
// fill="currentColor"
// />
// </svg>
// <span className="sr-only">Login with Meta</span>
// </Button>
// </div>
// );
// }
// export function LeagalFooter() {
// return (
// <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
// By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
// and <a href="#">Privacy Policy</a>.
// </div>
// );
// }

View File

@ -15,6 +15,8 @@ const globalForDb = globalThis as unknown as {
const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
export const db = drizzle(conn, { schema });
export const db = drizzle(conn, {
schema,
});
export type DBType = typeof db;

View File

@ -1,7 +1,6 @@
import { createId } from "@paralleldrive/cuid2";
import { relations, SQL, sql } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
AnyPgColumn,
boolean,
index,
integer,
@ -16,11 +15,7 @@ import { User } from "next-auth";
import { type AdapterAccount } from "next-auth/adapters";
import { JSONContent } from "novel";
export function lower(value: AnyPgColumn): SQL {
return sql`lower(${value})`;
}
export const createTable = pgTableCreator((name) => `wiki-antifa_${name}`);
export const createTable = pgTableCreator((name) => `logipedia_${name}`);
export const articles = createTable(
"article",
@ -43,7 +38,9 @@ export const articles = createTable(
),
},
(example) => ({
articleTitleIndex: index("article_title_idx").on(example.title),
titleIndex: index("article_title_idx").on(example.title),
slugIndex: index("article_slug_idx").on(example.slug),
createdAtIndex: index("article_created_at_idx").on(example.createdAt),
}),
);
export type Article = typeof articles.$inferSelect & {
@ -81,7 +78,9 @@ export const categories = createTable(
),
},
(example) => ({
categoryNameIndex: index("category_name_idx").on(example.name),
nameIndex: index("category_name_idx").on(example.name),
slugameIndex: index("category_slug_idx").on(example.slug),
createdAtIndex: index("category_created_at_idx").on(example.createdAt),
}),
);
export type Category = typeof categories.$inferSelect & {
@ -105,6 +104,7 @@ export const users = createTable("user", {
withTimezone: true,
}).default(sql`CURRENT_TIMESTAMP`),
image: varchar("image", { length: 255 }),
password: varchar("password", { length: 255 }),
});
export const usersRelations = relations(users, ({ many }) => ({

View File

@ -50,7 +50,7 @@
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted: 240 3.7% 5.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
@ -96,7 +96,7 @@
transform: scaleX(1); /* Slight overshoot for bounce effect */
}
100% {
transform: scaleX(0.2);
transform: scaleX(0);
}
}

View File

@ -69,5 +69,25 @@ export default {
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [
require("tailwindcss-animate"),
function ({ addBase, theme }: any) {
function extractColorVars(colorObj: any, colorGroup = "") {
return Object.keys(colorObj).reduce((vars: any, colorKey: any) => {
const value = colorObj[colorKey];
const newVars: any =
typeof value === "string"
? { [`--color${colorGroup}-${colorKey}`]: value }
: extractColorVars(value, `-${colorKey}`);
return { ...vars, ...newVars };
}, {});
}
addBase({
":root": extractColorVars(theme("colors")),
});
},
],
} satisfies Config;