7 Commits
1.0.2 ... main

Author SHA1 Message Date
25037f4798 Added administration and password change 2025-10-07 23:07:49 +02:00
38eee8b38c Added settings for changing password 2025-10-04 13:41:40 +02:00
9fbd6e4191 Hotfix 2025-10-03 11:58:59 +02:00
88a9778f3a Bump version to 1.0.3 2025-10-03 11:40:04 +02:00
c695d6c733 Added login and authentication 2025-10-03 11:39:20 +02:00
96388e5a50 update readme 2025-09-29 17:16:44 +02:00
888197b1c6 Readme update 2025-09-29 17:08:53 +02:00
16 changed files with 1091 additions and 48 deletions

View File

@@ -1,38 +1,34 @@
# sv # Jeopardy
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). Ein Jeopardy-Style Projekt
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing ## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: Zum entwickeln am besten `docker compose` nutzen. Wichtig hierbei ist, dass man das JeopardyServer Projekt auch gebaut haben muss.
```sh ```sh
npm run dev npm run docker-build
# or start the server and open the app in a new browser tab docker compose up -d
npm run dev -- --open
``` ```
## Building Eventuell muss das `docker-compose.yml` und das `.env.local` angepasst werden, sollte aber eigentlich alles so stimmen.
To create a production version of your app: Ansonsten kann man auch mit `npm run dev` entwickeln.
## Build Production
1. Versionsnummer in `package.json` updaten
2. commit erstellen und mit Versionsnummer taggen
3. push des commits **und der tags**
4. Auf Server connecten
```sh ```sh
npm run build sudo su
cd /opt/jeopardy/Jeopardy
git fetch --tags
git checkout <versionsnummer>
docker build -t jeopardy .
docker tag jeopardy:latest jeopardy:<versionsnummer>
cd ..
docker compose up -d
``` ```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@@ -4,11 +4,40 @@ services:
container_name: jeopardy container_name: jeopardy
environment: environment:
# domain:port or only domain, eg jeopardyserver.akolata.de # domain:port or only domain, eg jeopardyserver.akolata.de
- PUBLIC_JEOPARDY_SERVER=127.0.0.1:11001 PUBLIC_JEOPARDY_SERVER: localhost:11001
PUBLIC_JEOPARDY_SERVER_PROTOCOL: http
ports: ports:
- "11000:3000" - "11000:3000"
jeopardyserver: jeopardyserver:
image: jeopardyserver:latest image: jeopardyserver:latest
container_name: jeopardyserver container_name: jeopardyserver
environment:
JEOPARDYSERVER_MONGO_USERNAME: jeopardyadmin
JEOPARDYSERVER_MONGO_PASSWORD: jGpklsI9vCdixel7sDGxVBsydlzdyX8A1Zank6a12QT827PC
JEOPARDYSERVER_MONGO_URL: mongo:27017
JEOPARDY_URL: http://localhost:11000
ports: ports:
- "11001:12345" - "11001:12345"
mongo:
image: mongo:8.0.14
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: jeopardyadmin
MONGO_INITDB_ROOT_PASSWORD: jGpklsI9vCdixel7sDGxVBsydlzdyX8A1Zank6a12QT827PC
ports:
- 11002:27017
volumes:
- mongodb_data_volume:/data/db
mongo-express:
image: mongo-express
restart: always
ports:
- 11003:8081
environment:
ME_CONFIG_MONGODB_URL: mongodb://jeopardyadmin:jGpklsI9vCdixel7sDGxVBsydlzdyX8A1Zank6a12QT827PC@mongo:27017/
ME_CONFIG_BASICAUTH_ENABLED: true
ME_CONFIG_BASICAUTH_USERNAME: Trushy
ME_CONFIG_BASICAUTH_PASSWORD: IXvtRcbrXy7wX4VKfmwKzRdRnHwbMlDpLm4ETKk9jgzJoylhakUCpcRWN3xVbAuM
volumes:
mongodb_data_volume:

287
package-lock.json generated
View File

@@ -1,12 +1,16 @@
{ {
"name": "jeopardy", "name": "jeopardy",
"version": "1.0.2", "version": "1.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jeopardy", "name": "jeopardy",
"version": "1.0.2", "version": "1.0.5",
"dependencies": {
"axios": "^1.12.2",
"cookie": "^1.0.2"
},
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
@@ -1275,6 +1279,16 @@
} }
} }
}, },
"node_modules/@sveltejs/kit/node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": { "node_modules/@sveltejs/vite-plugin-svelte": {
"version": "6.1.3", "version": "6.1.3",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.1.3.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.1.3.tgz",
@@ -2011,6 +2025,23 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2052,6 +2083,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2135,6 +2179,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commondir": { "node_modules/commondir": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -2150,13 +2206,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">=18"
} }
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
@@ -2222,6 +2277,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -2239,6 +2303,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -2253,6 +2331,51 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.9", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
@@ -2680,6 +2803,42 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2699,12 +2858,48 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2731,6 +2926,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2755,11 +2962,37 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -3252,6 +3485,15 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3289,6 +3531,27 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -3765,6 +4028,12 @@
} }
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "jeopardy", "name": "jeopardy",
"private": true, "private": true,
"version": "1.0.2", "version": "1.0.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -34,5 +34,9 @@
"typescript": "^5.0.0", "typescript": "^5.0.0",
"typescript-eslint": "^8.20.0", "typescript-eslint": "^8.20.0",
"vite": "^7.0.4" "vite": "^7.0.4"
},
"dependencies": {
"axios": "^1.12.2",
"cookie": "^1.0.2"
} }
} }

View File

@@ -17,6 +17,17 @@
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
.btn:disabled {
color: grey !important;
border-color: gray !important;
cursor: unset !important;
}
.btn:disabled:hover {
background-color: unset !important;
cursor: unset !important;
}
.inputField { .inputField {
padding: 8px; padding: 8px;
} }

View File

@@ -6,6 +6,7 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"
/> />
<script src="https://kit.fontawesome.com/4115dc7344.js" crossorigin="anonymous"></script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover" class="size-full"> <body data-sveltekit-preload-data="hover" class="size-full">

26
src/lib/Auth.svelte.ts Normal file
View File

@@ -0,0 +1,26 @@
import axios from "axios";
import { env } from "$env/dynamic/public";
import UserSvelte, { type UserObj } from "./User.svelte";
import { goto } from "$app/navigation";
export async function isAuthenticated() {
if (location.pathname.startsWith("/login")) {
return true;
}
return axios
.get(`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/auth`, {
withCredentials: true
})
.then((res) => {
if (res.status === 200) {
UserSvelte.user = res.data as UserObj;
console.log(UserSvelte.id, UserSvelte.username, UserSvelte.role);
return true;
} else {
goto("/login");
}
})
.catch(() => {
goto("/login");
});
}

85
src/lib/Modal.svelte Normal file
View File

@@ -0,0 +1,85 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
showModal: boolean;
header: Snippet;
children: Snippet;
actionButtons?: Snippet;
cancelFn?: () => void;
okFn: () => Promise<boolean>;
oncloseFn?: () => void;
[key: string]: unknown;
}
let {
showModal = $bindable(),
header,
children,
cancelFn,
okFn,
oncloseFn,
actionButtons
}: Props = $props();
let dialog: HTMLDialogElement | undefined = $state(); // HTMLDialogElement
$effect(() => {
if (showModal) dialog?.showModal();
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<dialog
bind:this={dialog}
onclose={() => {
showModal = false;
if (oncloseFn) oncloseFn();
}}
onclick={(e) => {
if (e.target === dialog) dialog.close();
}}
class="rounded-md"
>
<div class="flex flex-col gap-4 p-4">
{@render header?.()}
{@render children?.()}
<!-- svelte-ignore a11y_autofocus -->
<div class="flex justify-end gap-4">
{@render actionButtons?.()}
<button
autofocus
onclick={() => {
dialog?.close();
if (cancelFn) cancelFn();
}}
class="btn">Abbrechen</button
>
<button
autofocus
onclick={async () => {
if (await okFn()) {
dialog?.close();
}
}}
class="btn min-w-[64px]">Ok</button
>
</div>
</div>
</dialog>
<style>
dialog {
position: fixed;
top: 50%;
left: 50%;
/* Move it back 50% relative to self */
-webkit-transform: translateX(-50%) translateY(-50%);
-moz-transform: translateX(-50%) translateY(-50%);
-ms-transform: translateX(-50%) translateY(-50%);
transform: translateX(-50%) translateY(-50%);
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
</style>

35
src/lib/User.svelte.ts Normal file
View File

@@ -0,0 +1,35 @@
let username: string = $state("");
let role: string = $state("");
let id: string = $state("");
export type UserObj = {
username: string;
role: string;
_id: string;
};
export default {
get username(): string {
return username;
},
set username(uname: string) {
username = uname;
},
get role(): string {
return role;
},
set role(newrole: string) {
role = newrole;
},
get id(): string {
return id;
},
set id(newid: string) {
id = newid;
},
set user(userobj: UserObj) {
username = userobj.username;
role = userobj.role;
id = userobj._id;
}
};

View File

@@ -14,7 +14,7 @@ let socket: WebSocket | undefined;
const connectAsHost = () => { const connectAsHost = () => {
if (socket !== undefined) return; if (socket !== undefined) return;
socket = new WebSocket( socket = new WebSocket(
`${location.protocol === "https:" ? "wss" : "ws"}://${env.PUBLIC_JEOPARDY_SERVER ?? "127.0.0.1:12345"}` `${location.protocol === "https:" ? "wss" : "ws"}://${env.PUBLIC_JEOPARDY_SERVER ?? "127.0.0.1:12345"}/websocket`
); );
socket.addEventListener("open", onOpen(SocketConnectionType.HOST)); socket.addEventListener("open", onOpen(SocketConnectionType.HOST));
socket.addEventListener("message", onFirstMessage); socket.addEventListener("message", onFirstMessage);
@@ -25,7 +25,7 @@ const connectAsHost = () => {
const connectAsDisplay = () => { const connectAsDisplay = () => {
if (socket !== undefined) return; if (socket !== undefined) return;
socket = new WebSocket( socket = new WebSocket(
`${location.protocol === "https:" ? "wss" : "ws"}://${env.PUBLIC_JEOPARDY_SERVER ?? "127.0.0.1:12345"}` `${location.protocol === "https:" ? "wss" : "ws"}://${env.PUBLIC_JEOPARDY_SERVER ?? "127.0.0.1:12345"}/websocket`
); );
socket.addEventListener("open", onOpen(SocketConnectionType.DISPLAY)); socket.addEventListener("open", onOpen(SocketConnectionType.DISPLAY));
socket.addEventListener("message", onFirstMessage); socket.addEventListener("message", onFirstMessage);
@@ -43,10 +43,12 @@ const sendMessage = (obj: unknown) => {
}; };
function onOpen(type: SocketConnectionType) { function onOpen(type: SocketConnectionType) {
return (event: Event) => { return async (event: Event) => {
console.log("Connection established"); console.log("Connection established");
console.log(event); console.log(event);
if (socket === undefined) return; if (socket === undefined) return;
// somehow beeing to fast so have to wait some time
await new Promise((r) => setTimeout(r, 100));
socket.send(type.toString()); socket.send(type.toString());
}; };
} }

View File

@@ -1,12 +1,28 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import favicon from "$lib/assets/favicon.svg"; import favicon from "$lib/assets/favicon.svg";
import { afterNavigate } from "$app/navigation";
import { isAuthenticated } from "$lib/Auth.svelte";
let { children } = $props(); let { children } = $props();
let renderit = $state(false);
afterNavigate(() => {
isAuthenticated()
.then(() => {
renderit = true;
})
.catch(() => {
renderit = true;
});
});
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
{#if renderit}
{@render children?.()} {@render children?.()}
{/if}

View File

@@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { env } from "$env/dynamic/public";
import UserSvelte from "$lib/User.svelte";
import websocket, { SocketConnectionType } from "$lib/websocket.svelte"; import websocket, { SocketConnectionType } from "$lib/websocket.svelte";
import axios from "axios";
$effect(() => { $effect(() => {
if (websocket.connectionType === SocketConnectionType.HOST) { if (websocket.connectionType === SocketConnectionType.HOST) {
@@ -12,17 +15,72 @@
goto("/connected/display"); goto("/connected/display");
} }
}); });
function logout() {
cookieStore
.delete("jeopardytoken")
.then(() => {
goto("/login");
})
.catch((e) => {
alert("Logout fehlgeschlagen!");
goto("/login");
});
}
async function logoutFromAllDevices() {
axios
.post(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/user/logout`,
{},
{
withCredentials: true
}
)
.then(() => {
logout();
})
.catch(() => {
alert("Logout fehlgeschlagen!");
});
}
</script> </script>
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<h1 class="m-4 mb-8 text-7xl font-bold">Jeopardy</h1> <div class="ms-4 me-4 flex items-center gap-4">
<h1 class="text-7xl font-bold">Jeopardy</h1>
<div class="grow"></div>
{#if UserSvelte.role === "admin"}
<button type="button" class="btn" onclick={() => goto("/admin")}>Administration</button>
{/if}
<button type="button" class="btn" onclick={() => goto("/settings")}>Einstellungen</button>
<button type="button" class="btn" onclick={logout}>Logout</button>
<button type="button" class="btn" onclick={logoutFromAllDevices}
>Logout von allen Geräten</button
>
<div class="btn profile ps-2 pe-2">
<i class="fa-regular fa-user"></i>
{UserSvelte.username}
</div>
</div>
<div class="flex h-full grow items-center justify-around p-4"> <div class="flex h-full grow items-center justify-around p-4">
<button class="btn m-2 h-1/2 w-1/2 text-5xl" onclick={websocket.connectAsHost} <button class="btn m-2 h-1/2 w-1/2 text-5xl" onclick={websocket.connectAsHost}
>Connect as Host</button >Spiel hosten</button
> >
<button class="btn m-2 h-1/2 w-1/2 text-5xl" onclick={websocket.connectAsDisplay} <button class="btn m-2 h-1/2 w-1/2 text-5xl" onclick={websocket.connectAsDisplay}
>Connect as Display</button >Spiel darstellen</button
> >
</div> </div>
</div> </div>
<style>
.profile {
border-color: gray;
}
.profile:hover {
background-color: unset !important;
cursor: unset !important;
}
</style>

View File

@@ -0,0 +1,350 @@
<script lang="ts">
import { afterNavigate, goto } from "$app/navigation";
import type { UserObj } from "$lib/User.svelte";
import { env } from "$env/dynamic/public";
import axios from "axios";
import { onMount } from "svelte";
import Modal from "$lib/Modal.svelte";
import UserSvelte from "$lib/User.svelte";
let users: UserObj[] = $state([]);
let roles: string[] = $state([]);
let showAddUser = $state(false);
let addUserName = $state("");
let showDeleteUser = $state(false);
let showResetPassword = $state(false);
let showChangeRole = $state(false);
let selectedUser: UserObj | undefined = $state(undefined);
let roleToChange: string = $state("");
let error = $state("");
async function loadUsers() {
console.log("loading users");
return axios
.get(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/admin/user/list`,
{
withCredentials: true
}
)
.then((response) => {
if (response.status === 200) {
users = response.data;
}
})
.catch((e) => {
console.log(e);
});
}
async function loadRoles() {
console.log("loading roles");
return axios
.get(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/admin/roles`,
{
withCredentials: true
}
)
.then((response) => {
if (response.status === 200) {
roles = response.data;
}
})
.catch((e) => {
console.log(e);
});
}
afterNavigate(() => {
if (UserSvelte.role !== "admin") goto("/");
});
onMount(() => {
if (UserSvelte.role !== "admin") goto("/");
loadRoles().then(loadUsers);
});
async function addUserOk(): Promise<boolean> {
error = "";
if (addUserName.length <= 0) {
error = "Gib einen Nutzernamen ein";
return false;
}
return axios
.put(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/admin/user`,
{
username: addUserName
},
{
withCredentials: true
}
)
.then((response) => {
if (response.status === 200) {
alert(`Passwort: ${response.data.password}`);
return true;
} else {
throw false;
}
})
.catch(() => {
error = "Etwas ist schief gelaufen";
return false;
});
}
async function deleteUserOk(): Promise<boolean> {
error = "";
if (selectedUser === undefined) {
error = "Etwas ist schief gelaufen. Kein Nutzer zum Löschen definiert.";
return false;
}
return axios
.delete(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/admin/user`,
{
withCredentials: true,
data: {
userid: selectedUser._id
}
}
)
.then(() => {
return true;
})
.catch(() => {
error = "Nutzer konnte nicht gelöscht werden";
return false;
});
}
async function resetPasswordOk(): Promise<boolean> {
error = "";
if (selectedUser === undefined) {
error = "Etwas ist schief gelaufen. Kein Nutzer zum Löschen definiert.";
return false;
}
return axios
.post(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/admin/user/resetpw`,
{
userid: selectedUser._id
},
{
withCredentials: true
}
)
.then((response) => {
if (response.status === 200) {
alert(`Passwort: ${response.data.password}`);
return true;
} else {
throw false;
}
})
.catch(() => {
error = "Etwas ist schief gelaufen";
return false;
});
}
async function changeRoleOk(): Promise<boolean> {
error = "";
if (selectedUser === undefined) {
error = "Etwas ist schief gelaufen. Kein Nutzer zum Löschen definiert.";
return false;
}
if (roleToChange === selectedUser.role) {
error = `${selectedUser.username} hat bereits die Rolle ${roleToChange}. Wähle eine andere Rolle aus.`;
return false;
}
return axios
.post(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/admin/user/changerole`,
{
userid: selectedUser._id,
role: roleToChange
},
{
withCredentials: true
}
)
.then((response) => {
if (response.status === 200) {
return true;
} else {
throw false;
}
})
.catch(() => {
error = "Etwas ist schief gelaufen";
return false;
});
}
function addUserCancel() {
error = "";
addUserName = "";
}
function selectedUserCancel() {
error = "";
selectedUser = undefined;
roleToChange = "";
}
</script>
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center">
<h2 class="text-4xl font-bold">Administration</h2>
<div class="grow"></div>
<button type="button" class="btn" onclick={() => goto("/")}>Zurück</button>
</div>
<div class="flex flex-col gap-2">
{#each users as user (user._id)}
<div class="flex justify-between gap-4 rounded-md border-1 p-2">
<div class="shrink-0 grow-0 text-center align-middle">{user._id}</div>
<div>{user.username}</div>
<div>{user.role}</div>
<div class="flex gap-2">
<!-- svelte-ignore a11y_consider_explicit_label -->
<button
type="button"
class="btn"
disabled={user._id === UserSvelte.id}
onclick={() => {
selectedUser = user;
roleToChange = selectedUser.role;
showChangeRole = true;
}}><i class="fa-solid fa-user"></i> Rolle ändern</button
>
<!-- svelte-ignore a11y_consider_explicit_label -->
<button
type="button"
class="btn"
disabled={user._id === UserSvelte.id}
onclick={() => {
selectedUser = user;
showResetPassword = true;
}}
><i class="fa-solid fa-arrow-rotate-right"></i> Passwort zurücksetzen</button
>
<!-- svelte-ignore a11y_consider_explicit_label -->
<button
disabled={user._id === UserSvelte.id}
type="button"
class="btn border-red-600 text-red-600"
onclick={() => {
selectedUser = user;
showDeleteUser = true;
}}><i class="fa-solid fa-trash"></i></button
>
</div>
</div>
{/each}
</div>
<button type="button" class="btn" onclick={() => (showAddUser = true)}>Nutzer hinzufügen</button
>
</div>
<Modal bind:showModal={showAddUser} okFn={addUserOk} cancelFn={addUserCancel} oncloseFn={loadUsers}>
{#snippet header()}
<h2 class="text-3xl">Nutzer hinzufügen</h2>
{/snippet}
<div>
<label for="username" class="">Name</label>
<div>
<input
type="text"
name="username"
id="username"
class="borders mt-2 mb-2 w-full"
bind:value={addUserName}
/>
</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</div>
</Modal>
<Modal
bind:showModal={showDeleteUser}
okFn={deleteUserOk}
cancelFn={selectedUserCancel}
oncloseFn={loadUsers}
>
{#snippet header()}
<h2 class="text-3xl">Nutzer löschen</h2>
{/snippet}
<div>Soll Nutzer {selectedUser?.username} wirklich gelöscht werden?</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</Modal>
<Modal
bind:showModal={showResetPassword}
okFn={resetPasswordOk}
cancelFn={selectedUserCancel}
oncloseFn={loadUsers}
>
{#snippet header()}
<h2 class="text-3xl">Passwort zurücksetzen</h2>
{/snippet}
<div>
Soll das Passwort von Nutzer {selectedUser?.username} wirklich zurückgesetzt werden?
</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</Modal>
<Modal
bind:showModal={showChangeRole}
okFn={changeRoleOk}
cancelFn={selectedUserCancel}
oncloseFn={loadUsers}
>
{#snippet header()}
<h2 class="text-3xl">Rolle von {selectedUser?.username} ändern</h2>
{/snippet}
<div>
{#if selectedUser}
<select name="roles" id="role-select" class="btn w-full" bind:value={roleToChange}>
{#each roles as role, index (index)}
<option value={role}>{role}</option>
{/each}
</select>
{/if}
</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</Modal>
<style>
.borders {
border: 1px solid black;
border-radius: 5px;
display: flex;
padding: 2px;
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { env } from "$env/dynamic/public";
import UserState, { type UserObj } from "$lib/User.svelte";
import axios from "axios";
let username = $state("");
let password = $state("");
let error = $state("");
async function login() {
axios
.post(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/auth/login`,
{
username: username,
password: password
},
{ withCredentials: true }
)
.then((response) => {
if (response.status === 200) {
UserState.user = response.data as UserObj;
console.log(UserState.id, UserState.username, UserState.role);
goto("/");
}
})
.catch((e) => {
error = "Login fehlgeschlagen";
});
}
</script>
<div class="flex h-full w-full items-center justify-center">
<div class="borders flex-col items-center justify-center">
<div>
<label for="username" class="ms-4">Name</label>
<input
type="text"
name="username"
id="username"
class="borders ms-4 me-4 mt-2 mb-4"
bind:value={username}
/>
<label for="password" class="ms-4">Passwort</label>
<input
type="password"
name="password"
id="password"
class="borders ms-4 me-4 mt-2 mb-4"
bind:value={password}
/>
</div>
<button type="button" class="btn mb-2 w-fit ps-4 pe-4" onclick={login}>Login</button>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</div>
</div>
<style>
.borders {
border: 1px solid black;
border-radius: 5px;
display: flex;
font-size: larger;
padding: 2px;
}
</style>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { env } from "$env/dynamic/public";
import UserState, { type UserObj } from "$lib/User.svelte";
import axios from "axios";
let oldpassword = $state("");
let newpassword = $state("");
let reppassword = $state("");
let error = $state("");
async function changePassword() {
if (newpassword !== reppassword) {
error = "Passwörter stimmen nicht überein.";
} else {
error = "";
}
axios
.post(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/user/changepw`,
{
old: oldpassword,
new: newpassword
},
{ withCredentials: true }
)
.then((response) => {
if (response.status === 200) {
goto("/login");
} else {
error = "Passwort ändern fehlgeschlagen";
}
})
.catch((e) => {
error = "Passwort ändern fehlgeschlagen";
});
}
</script>
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center">
<h2 class="text-4xl font-bold">Einstellungen</h2>
<div class="grow"></div>
<button type="button" class="btn" onclick={() => goto("/")}>Zurück</button>
</div>
<div class="rounded-md border-1 p-4">
<h4 class="font-bold">Passwort ändern</h4>
<div>
<label for="oldpassword" class="">Altes Passwort</label>
<input
type="password"
name="oldpassword"
id="oldpassword"
class="borders me-4 mt-2 mb-4"
bind:value={oldpassword}
/>
<label for="newpassword" class="">Neues Passwort</label>
<input
type="password"
name="newpassword"
id="newpassword"
class="borders me-4 mt-2 mb-4"
bind:value={newpassword}
/>
<label for="reppassword" class="">Neues Passwort wiederholen</label>
<input
type="password"
name="reppassword"
id="reppassword"
class="borders me-4 mt-2 mb-4"
bind:value={reppassword}
/>
</div>
<button type="button" class="btn mb-2 w-fit ps-4 pe-4" onclick={changePassword}
>Passwort ändern</button
>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</div>
</div>
<style>
.borders {
border: 1px solid black;
border-radius: 5px;
display: flex;
font-size: larger;
padding: 2px;
}
</style>

View File

@@ -1,7 +1,7 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()] plugins: [sveltekit(), tailwindcss()]
}); });