mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Compare commits
255
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae790dc6a2 | ||
|
|
4ed8358738 | ||
|
|
ba5026fa56 | ||
|
|
4cf79a164c | ||
|
|
e449f9987b | ||
|
|
419440a14f | ||
|
|
54225528b3 | ||
|
|
634dc8f987 | ||
|
|
77577d9b8d | ||
|
|
2feedc329c | ||
|
|
fb7dc0eb73 | ||
|
|
f515228db6 | ||
|
|
5e5cb38c70 | ||
|
|
4de8bbfee0 | ||
|
|
5897e562d1 | ||
|
|
733e02ddd8 | ||
|
|
85dda9486e | ||
|
|
54f73504ec | ||
|
|
b47e52406f | ||
|
|
1f8d4162f8 | ||
|
|
4c3b7434c9 | ||
|
|
218c4cce44 | ||
|
|
3d6eaf6b64 | ||
|
|
accfbc620c | ||
|
|
994618f71c | ||
|
|
b5024631f7 | ||
|
|
c5b91cf201 | ||
|
|
8665add560 | ||
|
|
119f8c552a | ||
|
|
22c71d367c | ||
|
|
c3893a9ca3 | ||
|
|
4260e742df | ||
|
|
a7039714aa | ||
|
|
174bab7494 | ||
|
|
9f0804a194 | ||
|
|
de0eb1b33b | ||
|
|
5e8d3f275d | ||
|
|
6a097c02ed | ||
|
|
9165a4ecf6 | ||
|
|
4bb27a2cbd | ||
|
|
66b0b7ba68 | ||
|
|
6e780811a2 | ||
|
|
68d1beea69 | ||
|
|
08fc200e47 | ||
|
|
eb9e68953b | ||
|
|
9b8d7109cf | ||
|
|
fae9a012aa | ||
|
|
2dc3546c98 | ||
|
|
a74a9d71f2 | ||
|
|
34ce90323a | ||
|
|
604f1ef4b6 | ||
|
|
43a76c4952 | ||
|
|
492f27cf29 | ||
|
|
cf5e8a1f24 | ||
|
|
7b16722e55 | ||
|
|
a037c7d512 | ||
|
|
c9b04682c9 | ||
|
|
03fbae616d | ||
|
|
87df12ac85 | ||
|
|
10c8dd5f0e | ||
|
|
4a43df9dd9 | ||
|
|
013a5acaa7 | ||
|
|
bd1d5af699 | ||
|
|
e4da258a8d | ||
|
|
4aeba17a1d | ||
|
|
0fddc05443 | ||
|
|
a3c69764c8 | ||
|
|
f0a279fa83 | ||
|
|
f1038e40f4 | ||
|
|
c5fb65db51 | ||
|
|
d4c67a7d81 | ||
|
|
0a1973f220 | ||
|
|
776ef1162a | ||
|
|
5b7bf0794c | ||
|
|
ab4d06498a | ||
|
|
d9fc465731 | ||
|
|
dca6861940 | ||
|
|
8161849504 | ||
|
|
3c2dfcf322 | ||
|
|
0341efdd50 | ||
|
|
aba492ff43 | ||
|
|
c2566cf3dd | ||
|
|
307af61726 | ||
|
|
740cbcecb4 | ||
|
|
e43cbe26ce | ||
|
|
95c4fb71ae | ||
|
|
d755822908 | ||
|
|
372378021d | ||
|
|
63e36c1c98 | ||
|
|
2f7845e63e | ||
|
|
156abc044a | ||
|
|
21ff710f83 | ||
|
|
fb863ba030 | ||
|
|
c7e6d31b9c | ||
|
|
9c9456693d | ||
|
|
5e81db2759 | ||
|
|
0b08a9a5d9 | ||
|
|
58b6448c3d | ||
|
|
3b77d91796 | ||
|
|
f0a09ac4d9 | ||
|
|
a732c1d119 | ||
|
|
da473ceb8f | ||
|
|
01d60e259c | ||
|
|
0aeb9fe47e | ||
|
|
fd4f3bf8f5 | ||
|
|
39c81e6b6e | ||
|
|
ef24270e7e | ||
|
|
13b90f83c4 | ||
|
|
cb77a3e266 | ||
|
|
61cb36f357 | ||
|
|
58f3bf7817 | ||
|
|
ccbc4ff4b0 | ||
|
|
aef760478c | ||
|
|
bda68ad293 | ||
|
|
af2f1ef707 | ||
|
|
3b4891412e | ||
|
|
ef588414db | ||
|
|
bc43184adc | ||
|
|
13fb8737a3 | ||
|
|
eaced63c68 | ||
|
|
b64cc6028c | ||
|
|
dbf26bb490 | ||
|
|
698dc368e6 | ||
|
|
0a8ef9a37e | ||
|
|
aeed963a75 | ||
|
|
128aae33ec | ||
|
|
b86697b6db | ||
|
|
cb1e395579 | ||
|
|
fbe1319183 | ||
|
|
94389d4e1c | ||
|
|
a66b4f5adb | ||
|
|
9498bb77ef | ||
|
|
6fe49c4f38 | ||
|
|
3985d5d089 | ||
|
|
253564376d | ||
|
|
f7e2b00358 | ||
|
|
d450613816 | ||
|
|
129d2ff49b | ||
|
|
ad0cb3e4b2 | ||
|
|
66365b106e | ||
|
|
f6ee9f91d4 | ||
|
|
67e84d8cf9 | ||
|
|
fda0357a73 | ||
|
|
e9e924165d | ||
|
|
937c5de6b8 | ||
|
|
53c5391f61 | ||
|
|
876cae6947 | ||
|
|
beb58e233e | ||
|
|
f7860c6197 | ||
|
|
5077f9c677 | ||
|
|
9be288d2de | ||
|
|
cd6f062556 | ||
|
|
c61f844e42 | ||
|
|
26e79690f8 | ||
|
|
fba92dad46 | ||
|
|
b3ed0e646e | ||
|
|
87b8ca5d04 | ||
|
|
2d95ca382f | ||
|
|
cdd22634e0 | ||
|
|
7e50c8e06c | ||
|
|
0685d71299 | ||
|
|
56049a0a29 | ||
|
|
9b5da4e818 | ||
|
|
ab63595fba | ||
|
|
52e9a13645 | ||
|
|
cfbc386c16 | ||
|
|
92173bb7e9 | ||
|
|
b4b8f8baee | ||
|
|
2fcca0c4b9 | ||
|
|
66434ff897 | ||
|
|
a711e08328 | ||
|
|
1616f62755 | ||
|
|
1d843f7355 | ||
|
|
d2cdd1304a | ||
|
|
254e4f75ed | ||
|
|
0f0f43b838 | ||
|
|
2dc40a2254 | ||
|
|
6e804f0ed4 | ||
|
|
3656868b66 | ||
|
|
cfce5ececa | ||
|
|
fb823814e0 | ||
|
|
f7780c3d98 | ||
|
|
dae9e841ca | ||
|
|
e622011b21 | ||
|
|
471b781d85 | ||
|
|
57d8f24930 | ||
|
|
6043226142 | ||
|
|
61d1871e95 | ||
|
|
ed4d04b658 | ||
|
|
963655c1f7 | ||
|
|
423548804a | ||
|
|
ac92950177 | ||
|
|
55a41dd3b5 | ||
|
|
529869671c | ||
|
|
6326b84a60 | ||
|
|
8cc006e3a4 | ||
|
|
d6ae6d818d | ||
|
|
6692ae2b9b | ||
|
|
fab02d03a9 | ||
|
|
9e44ebe415 | ||
|
|
019caf58e4 | ||
|
|
97ad1a7b50 | ||
|
|
cb4b9f429a | ||
|
|
5e11d2da11 | ||
|
|
a901ce005c | ||
|
|
4dd2a7cbe1 | ||
|
|
5cf375905d | ||
|
|
d7c5c756f3 | ||
|
|
44ecd3b87a | ||
|
|
1888781a47 | ||
|
|
860b257ea7 | ||
|
|
ba251fb933 | ||
|
|
84bd2620d8 | ||
|
|
b449d84c54 | ||
|
|
91ebaec22c | ||
|
|
58354b7cd8 | ||
|
|
31301419ad | ||
|
|
251139c159 | ||
|
|
a4ed4cf533 | ||
|
|
247f03b71a | ||
|
|
be22a121c8 | ||
|
|
261c6fc029 | ||
|
|
03b2993b56 | ||
|
|
2cc348687a | ||
|
|
2a3429106c | ||
|
|
a6720cfe39 | ||
|
|
4c292fd6ba | ||
|
|
5ba0021c37 | ||
|
|
19897e607f | ||
|
|
08e9191c4b | ||
|
|
aa95eda23c | ||
|
|
6bc37900d8 | ||
|
|
98f18627e5 | ||
|
|
19c44fbe52 | ||
|
|
c254d6c948 | ||
|
|
e66598f1a7 | ||
|
|
2a868ead48 | ||
|
|
b449ea455a | ||
|
|
5bea2ea78c | ||
|
|
77fe2d37b3 | ||
|
|
65ad72223b | ||
|
|
d612b2d6af | ||
|
|
b5e20766a3 | ||
|
|
e5f5590a67 | ||
|
|
e83d913852 | ||
|
|
8280f7073d | ||
|
|
4f3752060d | ||
|
|
cb26e1dcb0 | ||
|
|
9eb08b45f4 | ||
|
|
3b4a808131 | ||
|
|
a8a19ca902 | ||
|
|
6967e230d0 | ||
|
|
6c111a4475 | ||
|
|
15278dfab2 | ||
|
|
0644783cc4 |
No files matched your search
+3
-1
@@ -1,4 +1,6 @@
|
||||
**/node_modules
|
||||
.git
|
||||
**/dist
|
||||
**/tsconfig.tsbuildinfo
|
||||
**/tsconfig.tsbuildinfo
|
||||
data
|
||||
postgres-data
|
||||
@@ -0,0 +1,50 @@
|
||||
HOST=localhost
|
||||
PORT=3000
|
||||
BASE_URL=https://example.com
|
||||
SECRET_KEY=YOU_SHOULD_CHANGE_THIS
|
||||
BACKEND_URL=http://backend:3000
|
||||
DOCKER_NETWORK=linux-odyssey-players
|
||||
|
||||
MONGO_ROOT_PASSWORD=adminpassword
|
||||
MONGO_USER=commander
|
||||
MONGO_PASS=commander
|
||||
MONGO_DB=linux-odyssey
|
||||
MONGO_URL=mongodb://commander:commander@db:27017/linux-odyssey
|
||||
|
||||
# Admin user credentials for analytics
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
ADMIN_HOST=0.0.0.0
|
||||
ADMIN_PORT=3001
|
||||
# ADMIN_BASE_URL=http://0.0.0.0:3001
|
||||
|
||||
# Optional
|
||||
# CONTAINER_EXPIRY=60 # 60 minutes
|
||||
# SESSION_MAX_AGE=168 # 7 days
|
||||
# TRUSTED_PROXIES=1.2.3.4
|
||||
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||
|
||||
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
|
||||
# RATE_LIMIT_LOCKOUT_TIME=10
|
||||
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
|
||||
# Docker related config
|
||||
# DOCKER_NETWORK=host
|
||||
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||
# DOCKER_PREFIX=linuxodyssey/quest-
|
||||
|
||||
# To mount quest folder from host into container, for development purpose
|
||||
# MOUNT_QUEST=true
|
||||
|
||||
# LOG_PATH=
|
||||
|
||||
# Testing, create a user with the following credentials, disable rate limiting
|
||||
# TESTING=true
|
||||
# TESTING_USERNAME=alex
|
||||
# TESTING_PASSWORD=Alex1234
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
HOST=localhost
|
||||
PORT=3000
|
||||
BASE_URL=http://localhost:8000
|
||||
MONGO_URL=mongodb://localhost:27017/odyssey-test
|
||||
SECRET_KEY=sample-secret
|
||||
DOCKER_NETWORK=linux-odyssey-players-dev
|
||||
|
||||
# Optional
|
||||
# BACKEND_URL=http://host.docker.internal:3000
|
||||
# CONTAINER_EXPIRY=60 # 60 minutes
|
||||
# SESSION_MAX_AGE=168 # 7 days
|
||||
# TRUSTED_PROXIES=1.2.3.4
|
||||
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||
|
||||
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
|
||||
# RATE_LIMIT_LOCKOUT_TIME=10
|
||||
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
|
||||
# Docker related config
|
||||
# DOCKER_NETWORK=host
|
||||
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||
# DOCKER_PREFIX=linuxodyssey/quest-
|
||||
|
||||
# To mount quest folder from host into container, for development purpose
|
||||
# MOUNT_QUEST=true
|
||||
|
||||
# LOG_PATH=
|
||||
|
||||
# Testing, create a user with the following credentials, disable rate limiting
|
||||
# TESTING=true
|
||||
# TESTING_USERNAME=alex
|
||||
# TESTING_PASSWORD=Alex1234
|
||||
+2
-1
@@ -2,4 +2,5 @@ vite.config.js
|
||||
.gitignore
|
||||
data
|
||||
mongo-init.js
|
||||
dist
|
||||
dist
|
||||
postgres-data
|
||||
+36
-8
@@ -2,7 +2,9 @@ module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
globals: {
|
||||
Bun: 'readonly',
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'cypress', 'vue'],
|
||||
extends: [
|
||||
@@ -20,9 +22,12 @@ module.exports = {
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
extensions: ['.js', '.ts'],
|
||||
moduleDirectory: ['node_modules', 'packages', 'server/src'],
|
||||
},
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
vue: {
|
||||
version: '3.0', // Explicitly specify Vue 3
|
||||
@@ -49,7 +54,7 @@ module.exports = {
|
||||
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
'warn',
|
||||
{
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
@@ -63,21 +68,44 @@ module.exports = {
|
||||
|
||||
// Allow missing file extensions
|
||||
'import/extensions': [
|
||||
'warn',
|
||||
'off',
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'off',
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
'no-unused-vars': 'warn',
|
||||
'no-useless-constructor': 'off',
|
||||
'@typescript-eslint/no-useless-constructor': 'error',
|
||||
'no-empty-function': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'error',
|
||||
'no-shadow': 'off',
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'no-restricted-imports': ['error', '../*'],
|
||||
'import/no-relative-packages': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
|
||||
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'],
|
||||
rules: {
|
||||
'import/extensions': 'off', // Turn off the rule for test files
|
||||
'import/extensions': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['server/**/*.ts'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/packages/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,9 +18,9 @@ on:
|
||||
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: true
|
||||
required: false
|
||||
DOCKERHUB_TOKEN:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: always()
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -53,5 +54,5 @@ jobs:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfilePath }}
|
||||
target: ${{ inputs.target }}
|
||||
push: true
|
||||
push: ${{ secrets.DOCKERHUB_USERNAME != '' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: ./.github/workflows/build-docker-image.yml
|
||||
with:
|
||||
imageName: 'linuxodyssey/analytics'
|
||||
dockerfilePath: './packages/analytics/Dockerfile'
|
||||
dockerfilePath: './services/analytics/Dockerfile'
|
||||
target: prod
|
||||
secrets: inherit
|
||||
|
||||
|
||||
@@ -2,15 +2,6 @@ name: CI Checks
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
GOOGLE_CLIENT_ID:
|
||||
required: true
|
||||
GOOGLE_CLIENT_SECRET:
|
||||
required: true
|
||||
GH_CLIENT_ID:
|
||||
required: true
|
||||
GH_CLIENT_SECRET:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
@@ -20,18 +11,21 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 20
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: bun run type-check
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
run: bun run build
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
run: bun run test
|
||||
@@ -0,0 +1,103 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "dev" ]
|
||||
pull_request:
|
||||
branches: [ "main", "dev" ]
|
||||
schedule:
|
||||
- cron: '25 14 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
# or others). This is typically only required for manual builds.
|
||||
# - name: Setup runtime (example)
|
||||
# uses: actions/setup-example@v1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- name: Run manual build steps
|
||||
if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -1,6 +1,7 @@
|
||||
name: E2E Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
@@ -13,7 +14,7 @@ jobs:
|
||||
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
|
||||
|
||||
- name: Build Test Quests
|
||||
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
|
||||
run: docker-compose -f docker-compose.testing.yml run --rm backend bun run build:quests
|
||||
|
||||
- name: Run Cypress Tests
|
||||
env:
|
||||
|
||||
@@ -12,7 +12,6 @@ concurrency:
|
||||
jobs:
|
||||
ci-checks:
|
||||
uses: ./.github/workflows/ci-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
needs: ci-checks
|
||||
|
||||
@@ -10,7 +10,6 @@ concurrency:
|
||||
jobs:
|
||||
ci-checks:
|
||||
uses: ./.github/workflows/ci-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
|
||||
@@ -12,7 +12,6 @@ concurrency:
|
||||
jobs:
|
||||
ci-checks:
|
||||
uses: ./.github/workflows/ci-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
needs: ci-checks
|
||||
|
||||
+10
-1
@@ -1,3 +1,9 @@
|
||||
config
|
||||
.quest
|
||||
tmp
|
||||
services/terminal-service/terminal-service
|
||||
bin
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -19,6 +25,7 @@ pids
|
||||
*.seed
|
||||
*.pid.lock
|
||||
data
|
||||
postgres-data
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
@@ -85,6 +92,7 @@ built
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.prod
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
@@ -137,4 +145,5 @@ dist
|
||||
.pnp.*
|
||||
|
||||
#screenshots
|
||||
tests/cypress/screenshots
|
||||
tests/cypress/screenshots
|
||||
services/cmd-hook/cmd-hook
|
||||
Vendored
+4
-4
@@ -4,10 +4,10 @@
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "File Graph Yarn Jest",
|
||||
"runtimeExecutable": "yarn",
|
||||
"args": ["test", "--runInBand"],
|
||||
"cwd": "${workspaceFolder}/packages/file-graph",
|
||||
"name": "File Graph Tests",
|
||||
"runtimeExecutable": "bun",
|
||||
"args": ["test", "packages/file-graph"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
}
|
||||
|
||||
Vendored
+7
-1
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
"editor.formatOnSave": true,
|
||||
"yaml.schemas": {
|
||||
"packages/game/quest.json": "quests/**/game.yml"
|
||||
},
|
||||
"[go]": {
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
}
|
||||
}
|
||||
@@ -3,42 +3,85 @@
|
||||
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
|
||||
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml)
|
||||
|
||||
## TLDR
|
||||
|
||||
bun install
|
||||
cp .env.sample .env
|
||||
docker compose up -d
|
||||
bun run dev
|
||||
|
||||
## Requirements
|
||||
|
||||
- NodeJS
|
||||
- Bun: v1.3+
|
||||
- Docker
|
||||
- Yarn
|
||||
|
||||
## Known Issues
|
||||
|
||||
- MongoDB requires x86 architecture to run for v5+ versions. You can track [this issue](https://github.com/linux-odyssey/linux-odyssey/issues/166) for updates.
|
||||
|
||||
Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for setup instructions.
|
||||
|
||||
## Installation
|
||||
|
||||
git clone https://github.com/lancatlin/linux-odyssey.git
|
||||
cd linux-odyssey
|
||||
yarn install
|
||||
bun install
|
||||
|
||||
## Development
|
||||
## Project Structures
|
||||
|
||||
Run mongoDB first:
|
||||
1. **app**: frontend
|
||||
2. **server**: backend
|
||||
3. **quests**: tutorials scripts for each quest
|
||||
4. **packages**: other packages
|
||||
1. **analytics**: admin dashboard to analyze collected data
|
||||
2. **constants**: some shared config values for app and server
|
||||
3. **container**: the script run in container to capture users' command
|
||||
4. **file-graph**: the algorithm to construct and modify file graph, used in both app and server
|
||||
5. **models**: definitions of MondoDB collections (data schema)
|
||||
6. **utils**: other utility code shared between packages
|
||||
7. **game (in the future)**: core logic of game playing, will be extracted from server
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration is stored in the `.env` file. You can copy from the sample file.
|
||||
|
||||
cp .env.sample .env
|
||||
|
||||
## Docker Setup
|
||||
|
||||
### Docker compose files explaination
|
||||
|
||||
1. **docker-compose.yml**: The one used for development. It sets up MongoDB and builds base quest image.
|
||||
2. **docker-compose.prod.yml**: The one used for production deployment. It has a Nginx service, a backend, a database, an analytics service, and creates isolated networks for player containers.
|
||||
3. **docker-compose.testing.yml**: Similar to production config, but sets up an one-time environment for end-to-end testing.
|
||||
|
||||
### Common Development Setup
|
||||
|
||||
This sets up a development database, and running development server for frontend and backend.
|
||||
|
||||
Pull the latest images:
|
||||
|
||||
docker compose pull
|
||||
|
||||
Start the containers:
|
||||
|
||||
docker compose up -d
|
||||
|
||||
The db will automatically restarts by Docker.
|
||||
This will start the db. it will automatically restarts by Docker.
|
||||
|
||||
Build up dependencies:
|
||||
Building quest images:
|
||||
|
||||
yarn build:deps
|
||||
docker compose build base
|
||||
|
||||
Run dev server (frontend, backend):
|
||||
### Build Quest Images in Details
|
||||
|
||||
yarn dev
|
||||
In each quest folder, there is a `game.yml` file, which is the quest definition.
|
||||
|
||||
If you want to enable social login, you should have OAuth client token in a `.env` file:
|
||||
|
||||
### Build Quest Images
|
||||
|
||||
In each quest folder, there is a `game.yml` file, which is the quest definition. And a optional `Dockerfile` to overwrite the base image. If you want to include additional files, put them in a `home` folder. The files in `home` will **not** be copied to the image automatically, you have to copy them in the `Dockerfile`.
|
||||
If you want to include additional files, put them in a `home` folder. The files in `home` will be copied to the user's home on container creation automatically.
|
||||
|
||||
quests/
|
||||
├── Dockerfile # Base image
|
||||
├── entrypoint.sh # Entrypoint for the container
|
||||
├── discover
|
||||
│ └── game.yml
|
||||
├── helloworld
|
||||
@@ -54,18 +97,60 @@ In each quest folder, there is a `game.yml` file, which is the quest definition.
|
||||
└── home
|
||||
└── ancient_scroll.txt
|
||||
|
||||
An optional `Dockerfile` can overwrite the base image. You can use this to install additional dependencies or modify the environment.
|
||||
|
||||
Customized `entrypoint.sh` for each quest is not yet supported.
|
||||
|
||||
To build all quests:
|
||||
|
||||
yarn build:quests
|
||||
bun run build:quests
|
||||
|
||||
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
|
||||
|
||||
### Optional: Mounting Host Directory
|
||||
|
||||
To update the home content of a quest, simply re-create a new session will do. The home content is mounted and copied on container creation.
|
||||
|
||||
To further improve the development experience, we can mount the host directory to the container in the pattern:
|
||||
|
||||
/path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
|
||||
|
||||
You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables:
|
||||
|
||||
MOUNT_QUEST=true
|
||||
|
||||
### Optional: Mounting terminal service & cmd-hook
|
||||
|
||||
To make the development of terminal services & cmd-hook smoother, we can enable `MOUNT_CLI` to mount the local binaries into container.
|
||||
|
||||
MOUNT_CLI
|
||||
|
||||
And you can run `air` inside of `services/terminal-service` or `services/cmd-hook` to get auto recompiling.
|
||||
|
||||
## Development
|
||||
|
||||
Copy `.env.sample` to `.env` and customize it for your needs.
|
||||
|
||||
Run dev server (frontend, backend):
|
||||
|
||||
bun run dev
|
||||
|
||||
To develop analytics dashboard:
|
||||
|
||||
bun run analytics
|
||||
|
||||
The default username and password is `alex` and `Alex1234`.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests:
|
||||
|
||||
bun run test
|
||||
|
||||
Run Cypress locally, using the current development containers:
|
||||
|
||||
docker compose up -d
|
||||
yarn test:e2e
|
||||
bun run test:e2e
|
||||
|
||||
Run Cypress in container, create brand-new containers along with it:
|
||||
|
||||
@@ -78,76 +163,37 @@ Run Cypress in container, create brand-new containers along with it:
|
||||
# tearing off the containers
|
||||
./docker-scripts.sh down
|
||||
|
||||
### Deployment
|
||||
## Deployment
|
||||
|
||||
Create production env
|
||||
|
||||
cp .env.prod.sample .env.prod
|
||||
|
||||
Edit `.env.prod`
|
||||
|
||||
BASE_URL=https://yourdomain.com # IMPORTANT to make login work
|
||||
SECRET_KEY=xxxx # Change it to something safe!
|
||||
|
||||
Start the service
|
||||
|
||||
# Build the images (only allowed for maintainers)
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml push
|
||||
|
||||
# Server side
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
bun run build:quests
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
### Necessary Configuration
|
||||
|
||||
To enable social login, you should have OAuth client token in a `.env` file:
|
||||
You can copy from `example.env`
|
||||
You can copy from `.env.sample`
|
||||
|
||||
# .env
|
||||
BASE_URL=https://example.com
|
||||
GOOGLE_CLIENT_ID=...
|
||||
GOOGLE_CLIENT_SECRET=...
|
||||
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
|
||||
|
||||
To enable it:
|
||||
SECRET_KEY=your-secret-key
|
||||
|
||||
docker compose --env-file .env -f docker-compose.prod.yml up -d
|
||||
You can generate a random key by running
|
||||
|
||||
## Standalone App (without Docker)
|
||||
|
||||
Source: `app/`
|
||||
|
||||
# Connect to dev server (https://odyssey.wancat.cc)
|
||||
yarn app
|
||||
|
||||
# Connect to localhost (http://localhost:3000)
|
||||
yarn app:local
|
||||
|
||||
# Connect to anyhost
|
||||
API_ENDPOINT=http://example.com yarn app:local
|
||||
|
||||
## Swagger
|
||||
|
||||
docker compose up -d swagger
|
||||
|
||||
Open http://localhost:8080 to open Swagger
|
||||
|
||||
## CLI terminal client (Docker not required)
|
||||
|
||||
yarn cli
|
||||
Usage: client [options] [command]
|
||||
|
||||
Options:
|
||||
-s, --session <string> Session ID
|
||||
-c, --create Create a new session
|
||||
-h, --host <string> Server host (default: "http://localhost:3000")
|
||||
--help display help for command
|
||||
|
||||
Commands:
|
||||
list List all sessions
|
||||
|
||||
connet to recent session:
|
||||
|
||||
yarn cli
|
||||
|
||||
connect to new session:
|
||||
|
||||
yarn cli -c
|
||||
|
||||
connect to specific session:
|
||||
|
||||
yarn cli -s 64d0de0367459c13004bc83f
|
||||
|
||||
connect to other host:
|
||||
|
||||
yarn cli -h https://example.com
|
||||
|
||||
list sessions:
|
||||
|
||||
yarn cli list
|
||||
bun run setup
|
||||
+16
-20
@@ -1,31 +1,27 @@
|
||||
# Base stage
|
||||
FROM node:20-alpine as base
|
||||
FROM oven/bun:1.3.14-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock tsconfig.json ./
|
||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
COPY packages/constants/package.json ./packages/constants/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
COPY package.json bun.lock ./
|
||||
COPY app/package.json ./app/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY server/package.json ./server/package.json
|
||||
COPY services/analytics/package.json ./services/analytics/package.json
|
||||
COPY tests/package.json ./tests/package.json
|
||||
COPY packages/constants/package.json ./packages/constants/package.json
|
||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||
COPY packages/game/package.json ./packages/game/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
RUN bun install --frozen-lockfile --filter app
|
||||
|
||||
# Dev stage
|
||||
FROM base as dev
|
||||
WORKDIR /app/app
|
||||
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
|
||||
|
||||
# Build stage
|
||||
FROM base as build
|
||||
COPY packages/file-graph ./packages/file-graph
|
||||
COPY packages/utils ./packages/utils
|
||||
COPY packages/constants ./packages/constants
|
||||
COPY packages/models ./packages/models
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY server ./server
|
||||
COPY packages ./packages
|
||||
COPY app ./app
|
||||
RUN yarn workspace app build
|
||||
RUN bun run --filter app build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:stable-alpine as prod
|
||||
FROM nginx:stable-alpine AS prod
|
||||
COPY --from=build /app/app/dist /usr/share/nginx/html
|
||||
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
|
||||
+18
-14
@@ -5,8 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:docker": "API_TARGET=http://backend:3000 vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"type-check": "vue-tsc -b",
|
||||
"build": "vue-tsc && vite build",
|
||||
"type-check": "vue-tsc",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,18 +16,21 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||
"@linux-odyssey/constants": "*",
|
||||
"@linux-odyssey/file-graph": "*",
|
||||
"@linux-odyssey/models": "*",
|
||||
"@linux-odyssey/utils": "*",
|
||||
"@odyssey/constants": "workspace:*",
|
||||
"@odyssey/file-graph": "workspace:*",
|
||||
"@odyssey/game": "workspace:*",
|
||||
"@odyssey/utils": "workspace:*",
|
||||
"@trpc/client": "^11.1.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"axios": "^1.6.0",
|
||||
"dompurify": "^3.0.6",
|
||||
"axios": "^1.20.0",
|
||||
"dompurify": "^3.4.16",
|
||||
"marked": "^10.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"socket.io-client": "^4.7.1",
|
||||
"validator": "^13.11.0",
|
||||
"validator": "^13.15.22",
|
||||
"vue": "^3.3.4",
|
||||
"vue-i18n": "11.3.0",
|
||||
"vue-router": "^4.2.4",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"vue3-lottie": "^3.2.0",
|
||||
@@ -37,13 +40,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/validator": "^13.11.9",
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"@vitejs/plugin-vue": "^5.2.2",
|
||||
"@vue/compiler-sfc": "^3.3.13",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss": "^8.5.23",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^4.5.3",
|
||||
"vue-tsc": "^2.0.10"
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.4.3",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '../utils/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits(['onSubmit', 'onChange'])
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
@@ -110,7 +113,7 @@ const registerGuest = async () => {
|
||||
:icon="['fas', 'circle-exclamation']"
|
||||
class="px-1 text-sm"
|
||||
/>
|
||||
<span>建議使用電腦全螢幕執行</span>
|
||||
<span>{{ t('authform.recommand_pc_fullscreen') }}</span>
|
||||
</div>
|
||||
<form @submit.prevent="handleSubmit()" class="w-full">
|
||||
<div v-if="socialLogin && hasSocialLogins">
|
||||
@@ -124,7 +127,7 @@ const registerGuest = async () => {
|
||||
:icon="['fab', 'google']"
|
||||
class="text-text-secondary px-3 text-lg"
|
||||
/>
|
||||
<span class="font-medium"> 以 Google 繼續 </span>
|
||||
<span class="font-medium"> {{ t('authform.google_login') }} </span>
|
||||
</a>
|
||||
<a
|
||||
id="GitHubLogin"
|
||||
@@ -136,14 +139,14 @@ const registerGuest = async () => {
|
||||
:icon="['fab', 'github']"
|
||||
class="text-text-secondary px-3 text-lg"
|
||||
/>
|
||||
<span class="font-medium"> 以 GitHub 繼續 </span>
|
||||
<span class="font-medium"> {{ t('authform.github_login') }} </span>
|
||||
</a>
|
||||
<p
|
||||
id="or"
|
||||
class="text-text-secondary flex justify-center mt-3"
|
||||
v-if="type === 'login' || type === 'register'"
|
||||
>
|
||||
或
|
||||
{{ t('authform.or') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
@@ -152,20 +155,22 @@ const registerGuest = async () => {
|
||||
id="username"
|
||||
ref="usernameInput"
|
||||
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
||||
:placeholder="type === 'login' ? '電子郵件 / 帳號名稱' : '帳號名稱'"
|
||||
:placeholder="
|
||||
t('authform.username') +
|
||||
(type === 'login' ? ' / ' + t('authform.email') : '')
|
||||
"
|
||||
v-model="username"
|
||||
required
|
||||
@input="handleChange()"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<label
|
||||
class="text-sm font-normal text-text-secondary"
|
||||
<p
|
||||
class="mx-1 text-xs font-normal text-text-secondary"
|
||||
id="NameRules"
|
||||
v-if="type === 'username' || type === 'register'"
|
||||
v-if="type === 'register'"
|
||||
>
|
||||
<p>可用小寫字母、數字、"_"和"-"</p>
|
||||
<p>小寫字母開頭,至多32字元</p>
|
||||
</label>
|
||||
{{ t('authform.username_rules') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6" v-if="type === 'register'">
|
||||
<input
|
||||
@@ -173,7 +178,7 @@ const registerGuest = async () => {
|
||||
id="email"
|
||||
ref="emailInput"
|
||||
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
||||
placeholder="電子郵件"
|
||||
:placeholder="t('authform.email')"
|
||||
v-model="email"
|
||||
required
|
||||
@input="handleChange()"
|
||||
@@ -185,11 +190,18 @@ const registerGuest = async () => {
|
||||
type="password"
|
||||
id="password"
|
||||
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
||||
placeholder="密碼"
|
||||
:placeholder="t('authform.password')"
|
||||
v-model="password"
|
||||
required
|
||||
@input="handleChange()"
|
||||
/>
|
||||
<p
|
||||
class="mx-1 text-xs font-normal text-text-secondary"
|
||||
id="PasswordRules"
|
||||
v-if="type === 'register'"
|
||||
>
|
||||
{{ t('authform.password_rules') }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
id="ErrorDisplay"
|
||||
@@ -203,29 +215,29 @@ const registerGuest = async () => {
|
||||
class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="type === 'login'"> 登入 </span>
|
||||
<span v-else> 註冊 </span>
|
||||
<span v-if="type === 'login'"> {{ t('authform.login') }} </span>
|
||||
<span v-else> {{ t('authform.register') }} </span>
|
||||
</button>
|
||||
|
||||
<p id="SwitchLoginRegister" class="text-text flex justify-center mt-3">
|
||||
<span id="CheckRegistered" v-if="type === 'register'">
|
||||
已經有帳號了嗎?
|
||||
{{ t('authform.already_registered') }}
|
||||
<RouterLink
|
||||
id="SwitchtoLogin"
|
||||
class="text-text-primary font-bold"
|
||||
to="/login"
|
||||
>
|
||||
<u> 登入 </u>
|
||||
<u> {{ t('authform.login') }} </u>
|
||||
</RouterLink></span
|
||||
>
|
||||
<span id="CheckRegistered" v-else-if="type === 'login'">
|
||||
還沒有帳號嗎?
|
||||
{{ t('authform.not_registered') }}
|
||||
<RouterLink
|
||||
id="SwitchtoRegister"
|
||||
class="text-text-primary font-bold"
|
||||
to="/register"
|
||||
>
|
||||
<u> 註冊 </u>
|
||||
<u> {{ t('authform.register') }} </u>
|
||||
</RouterLink></span
|
||||
>
|
||||
</p>
|
||||
@@ -234,7 +246,7 @@ const registerGuest = async () => {
|
||||
class="rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
|
||||
@click="registerGuest()"
|
||||
>
|
||||
以訪客身分繼續
|
||||
{{ t('authform.continue_as_guest') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ function nodeClass() {
|
||||
<template>
|
||||
<a href="#" :class="nodeClass()">
|
||||
<font-awesome-icon
|
||||
v-if="node.type === 'folder'"
|
||||
v-if="node.type === 'directory'"
|
||||
:icon="['far', 'folder']"
|
||||
class="text-text-primary size-8"
|
||||
/>
|
||||
|
||||
@@ -31,11 +31,31 @@ const html = computed(() => {
|
||||
.markdown-content :deep(ol) {
|
||||
list-style: decimal;
|
||||
padding-left: 1em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.markdown-content :deep(code) {
|
||||
background-color: #282a33;
|
||||
.markdown-content :deep(ul) {
|
||||
list-style: disc;
|
||||
padding-left: 1em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.markdown-content :deep(pre) {
|
||||
background-color: #2e3440;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #d8dee9;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #4c566a;
|
||||
line-height: 1.5;
|
||||
font-family: 'monospace';
|
||||
}
|
||||
|
||||
.markdown-content :deep(code:not(pre code)) {
|
||||
background-color: #2e3440;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
color: #d8dee9;
|
||||
font-family: 'monospace';
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { ref, defineProps, onMounted } from 'vue'
|
||||
import { getQuest } from '../utils/api'
|
||||
import MarkdownText from './MarkdownText.vue'
|
||||
import { trpc } from '../utils/trpc'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
questTitle: {
|
||||
@@ -36,7 +39,7 @@ const handleQuests = (id: string) => {
|
||||
if (questUnlocked) {
|
||||
router.push({ name: 'game', params: { questId: id } })
|
||||
} else {
|
||||
toast.warning('你還沒完成前一個關卡!')
|
||||
toast.warning(t('quest.locked'))
|
||||
}
|
||||
}
|
||||
const handleColour = () => {
|
||||
@@ -49,8 +52,15 @@ const handleColour = () => {
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
const quest = await getQuest(questId)
|
||||
questInstruction.value = quest.instruction
|
||||
try {
|
||||
const quest = await trpc.quests.getQuestDetail.query({
|
||||
questId,
|
||||
locale: locale.value,
|
||||
})
|
||||
questInstruction.value = quest.instruction
|
||||
} catch (error) {
|
||||
toast.error(t('quest.load_quest_instruction_failed'), error)
|
||||
}
|
||||
handleColour()
|
||||
})
|
||||
</script>
|
||||
@@ -61,7 +71,6 @@ onMounted(async () => {
|
||||
<span class="close text-text content-between" @click="closeModal"
|
||||
>×</span
|
||||
>
|
||||
|
||||
<h2
|
||||
id="QuestTitle"
|
||||
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
|
||||
@@ -81,9 +90,10 @@ onMounted(async () => {
|
||||
target="_blank"
|
||||
@click="handleQuests(questId)"
|
||||
:style="{ backgroundColor: questColor, color: questTextColor }"
|
||||
class="inline-flex justify-center rounded-lg text-base font-black py-3 mt-5 w-full text-lg"
|
||||
>開始冒險</a
|
||||
class="inline-flex justify-center rounded-lg text-base font-black py-3 mt-5 w-full"
|
||||
>
|
||||
{{ t('quest.start') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { DAG } from '@linux-odyssey/utils'
|
||||
import { getQuests } from '../utils/api'
|
||||
import { DAG } from '@odyssey/utils'
|
||||
import { trpc } from '../utils/trpc'
|
||||
import useUserProfile from '../store/userProfile'
|
||||
import QuestIntro from './QuestIntro.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const store = useUserProfile()
|
||||
const toast = useToast()
|
||||
|
||||
const svgWidth = ref(window.innerWidth)
|
||||
const svgHeight = ref(window.innerHeight)
|
||||
|
||||
const marginX = 120
|
||||
const marginY = 120
|
||||
|
||||
const nodeWidth = 160
|
||||
const nodeHeight = 50
|
||||
|
||||
const opened = ref<Node | null>(null)
|
||||
type Node = {
|
||||
id: string
|
||||
@@ -44,14 +44,18 @@ const dragStartY = ref(0)
|
||||
const offsetX = ref(0)
|
||||
const offsetY = ref(0)
|
||||
|
||||
function startDrag(event: MouseEvent) {
|
||||
function startDrag(event: PointerEvent) {
|
||||
isDragging.value = true
|
||||
dragStartX.value = event.clientX - offsetX.value
|
||||
dragStartY.value = event.clientY - offsetY.value
|
||||
}
|
||||
|
||||
function drag(event: MouseEvent) {
|
||||
function drag(event: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
// Capture only once dragging starts so plain clicks still reach the node buttons
|
||||
const el = event.currentTarget as HTMLElement
|
||||
if (!el.hasPointerCapture(event.pointerId))
|
||||
el.setPointerCapture(event.pointerId)
|
||||
offsetX.value = event.clientX - dragStartX.value
|
||||
offsetY.value = event.clientY - dragStartY.value
|
||||
}
|
||||
@@ -64,19 +68,18 @@ async function computeGraphData() {
|
||||
if (!store.progress) return
|
||||
|
||||
try {
|
||||
const quests = await getQuests()
|
||||
const quests = await trpc.quests.getQuests.query({ locale: locale.value })
|
||||
|
||||
const dag = new DAG(quests)
|
||||
const nodesValues = dag.getNodes()
|
||||
|
||||
const nodes = nodesValues.map((node) => ({
|
||||
id: node._id,
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
x: marginX * node.layer * 2,
|
||||
y:
|
||||
marginY * node.index -
|
||||
(marginX * dag.getLayer(node._id)) / 2 +
|
||||
svgHeight.value / 2,
|
||||
completed: store.progress[node._id]?.completed || false,
|
||||
// Centered on 0; the world container is anchored at the viewport's vertical center
|
||||
y: (node.index - (dag.getLayer(node.id) + 1) / 2) * marginY,
|
||||
completed: store.progress[node.id]?.completed || false,
|
||||
unlocked: node.requirements.every(
|
||||
(req: string) => store.progress[req]?.completed
|
||||
),
|
||||
@@ -96,8 +99,8 @@ async function computeGraphData() {
|
||||
|
||||
graphData.value = { nodes, edges }
|
||||
} catch (error) {
|
||||
console.error('Error computing graph data:', error)
|
||||
toast.error('Failed to load quest data')
|
||||
console.error(t('quest_map.compute_graph_failed'), error)
|
||||
toast.error(t('quest_map.load_quest_data_failed'))
|
||||
}
|
||||
}
|
||||
function handleNodeClick(node: Node) {
|
||||
@@ -115,15 +118,15 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
watch(() => store.progress, computeGraphData, { deep: true })
|
||||
watch(locale, () => {
|
||||
opened.value = null
|
||||
computeGraphData()
|
||||
})
|
||||
|
||||
const curvedPath = computed(() => {
|
||||
const edgePath = computed(() => {
|
||||
return (source: Node, target: Node) => {
|
||||
// const dx = target.x - source.x
|
||||
// const dy = target.y - source.y
|
||||
// const dr = Math.sqrt(dx * dx + dy * dy)
|
||||
const dr = 0
|
||||
const sweep = target.index > source.index ? 0 : 1 // Determines if the arc should curve up or down based on node index
|
||||
return `M${source.x},${source.y}A${dr},${dr} 0 0,${sweep} ${target.x},${target.y}`
|
||||
const mx = (source.x + target.x) / 2
|
||||
return `M${source.x},${source.y}C${mx},${source.y} ${mx},${target.y} ${target.x},${target.y}`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -151,54 +154,46 @@ const edgeStyle = computed(() => {
|
||||
class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
|
||||
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
|
||||
>
|
||||
踏上你的Linux冒險之旅吧!
|
||||
{{ t('quest_map.intro') }}
|
||||
</h1>
|
||||
<svg
|
||||
:width="svgWidth"
|
||||
:height="svgHeight"
|
||||
class="absolute"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="endDrag"
|
||||
@mouseleave="endDrag"
|
||||
<div
|
||||
class="absolute inset-0 overflow-hidden select-none"
|
||||
:class="isDragging ? 'cursor-grabbing' : 'cursor-grab'"
|
||||
style="touch-action: none"
|
||||
@pointerdown="startDrag"
|
||||
@pointermove="drag"
|
||||
@pointerup="endDrag"
|
||||
@pointercancel="endDrag"
|
||||
>
|
||||
<g :transform="`translate(${offsetX}, ${offsetY})`">
|
||||
<g
|
||||
v-for="edge in graphData.edges"
|
||||
:key="`${edge.source.id}-${edge.target.id}`"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1/2 left-0"
|
||||
:style="{ transform: `translate(${offsetX}px, ${offsetY}px)` }"
|
||||
>
|
||||
<svg class="absolute overflow-visible pointer-events-none">
|
||||
<path
|
||||
:d="curvedPath(edge.source, edge.target)"
|
||||
v-for="edge in graphData.edges"
|
||||
:key="`${edge.source.id}-${edge.target.id}`"
|
||||
:d="edgePath(edge.source, edge.target)"
|
||||
:class="['edge', edgeStyle(edge)]"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
</svg>
|
||||
<button
|
||||
v-for="node in graphData.nodes"
|
||||
:key="node.id"
|
||||
type="button"
|
||||
:class="['node', nodeStyle(node)]"
|
||||
:style="{
|
||||
left: `${node.x - nodeWidth / 2}px`,
|
||||
top: `${node.y - nodeHeight / 2}px`,
|
||||
width: `${nodeWidth}px`,
|
||||
height: `${nodeHeight}px`,
|
||||
}"
|
||||
@click="handleNodeClick(node)"
|
||||
>
|
||||
<rect
|
||||
:x="node.x - nodeWidth / 2"
|
||||
:y="node.y - nodeHeight / 2"
|
||||
:width="nodeWidth"
|
||||
:height="nodeHeight"
|
||||
rx="10"
|
||||
ry="10"
|
||||
:class="['node', nodeStyle(node)]"
|
||||
/>
|
||||
<text
|
||||
:x="node.x"
|
||||
:y="node.y"
|
||||
text-anchor="middle"
|
||||
alignment-baseline="middle"
|
||||
:class="['node-text', nodeStyle(node)]"
|
||||
font-size="18"
|
||||
>
|
||||
{{ node.title }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="line-clamp-2">{{ node.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<QuestIntro
|
||||
:questTitle="opened.title"
|
||||
:questId="opened.id"
|
||||
@@ -211,44 +206,49 @@ const edgeStyle = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.node {
|
||||
&.completed {
|
||||
fill: #6cf76c;
|
||||
}
|
||||
&.unlocked {
|
||||
fill: #8c8c92;
|
||||
}
|
||||
&.locked {
|
||||
fill: #505050;
|
||||
}
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.5rem;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.node-text {
|
||||
&.completed {
|
||||
fill: #1d1d1d;
|
||||
}
|
||||
&.unlocked {
|
||||
fill: #ffffff;
|
||||
}
|
||||
&.locked {
|
||||
fill: #a0a0a0;
|
||||
}
|
||||
.node:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.node:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #00ff00;
|
||||
}
|
||||
.node.completed {
|
||||
background-color: #6cf76c;
|
||||
color: #1d1d1d;
|
||||
}
|
||||
.node.unlocked {
|
||||
background-color: #8c8c92;
|
||||
color: #ffffff;
|
||||
}
|
||||
.node.locked {
|
||||
background-color: #505050;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.edge {
|
||||
stroke-width: 3;
|
||||
fill: none;
|
||||
&.unlocked {
|
||||
stroke: #adadb5;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
&.locked {
|
||||
stroke: #454552;
|
||||
stroke-dasharray: 5, 5;
|
||||
}
|
||||
}
|
||||
.edge.unlocked {
|
||||
stroke: #adadb5;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
.edge.locked {
|
||||
stroke: #454552;
|
||||
stroke-dasharray: 5, 5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
|
||||
<h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1>
|
||||
<h1 id="CommandBook" class="inline text-text-primary font-xl">
|
||||
{{ t('game.command_book') }}
|
||||
</h1>
|
||||
<div id="cheatsheets">
|
||||
<p class="text-text-secondary">-- --指令列表-- --</p>
|
||||
<p class="text-text-secondary">-- --{{ t('game.cheatsheets') }}-- --</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { ref } from 'vue'
|
||||
@@ -6,6 +7,8 @@ import { Vue3Lottie } from 'vue3-lottie'
|
||||
// import { surveyUrl } from '../config'
|
||||
import CompleteAnimation from '../../img/complete-animation.json'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const showModal = ref(true) // Controls whether the modal is visible or not
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -29,7 +32,7 @@ const backtoMap = async () => {
|
||||
try {
|
||||
router.push({ name: 'map' })
|
||||
} catch (err) {
|
||||
toast.error('無法讀取地圖')
|
||||
toast.error(t('quest_map.load_quest_map_failed'))
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +52,7 @@ setTimeout(() => {
|
||||
id="QuestCompleted"
|
||||
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
|
||||
>
|
||||
關卡完成!
|
||||
{{ t('game.quest_completed') }}
|
||||
</h2>
|
||||
<div class="z-10 right-1/2 top-1/2" @animationend="defineComponent">
|
||||
<Vue3Lottie
|
||||
@@ -58,16 +61,18 @@ setTimeout(() => {
|
||||
:width="200"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-base text-text text-center">恭喜你完成這一關!</p>
|
||||
<p class="text-base text-text text-center">
|
||||
接下來回到地圖,迎接新的挑戰吧!
|
||||
{{ t('game.quest_completed_desc_1') }}
|
||||
</p>
|
||||
<p class="text-base text-text text-center">
|
||||
{{ t('game.quest_completed_desc_2') }}
|
||||
</p>
|
||||
<a
|
||||
id="BacktoMap"
|
||||
target="_blank"
|
||||
@click="backtoMap"
|
||||
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
|
||||
>回到地圖</a
|
||||
>{{ t('game.back_to_map') }}</a
|
||||
>
|
||||
</div>
|
||||
<div style="position: relative; height: 100vh; overflow: auto">
|
||||
|
||||
@@ -1,29 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import useSession from '../../store/session'
|
||||
import type { QuestLocale } from '@odyssey/constants'
|
||||
import { useSession } from '../../store/session'
|
||||
import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
|
||||
import { trpc } from '../../utils/trpc'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const router = useRouter()
|
||||
const sessionStore = useSession()
|
||||
const reset = async () => {
|
||||
console.log('resetting...')
|
||||
await sessionStore.createSession()
|
||||
const selectedLocale = locale.value as QuestLocale
|
||||
const questId = sessionStore.session?.quest
|
||||
|
||||
if (!questId) return
|
||||
|
||||
try {
|
||||
const quests = await trpc.quests.getQuests.query({
|
||||
locale: selectedLocale,
|
||||
})
|
||||
|
||||
console.log({
|
||||
selectedLocale,
|
||||
questId,
|
||||
availableQuestIds: quests.map((quest) => quest.id),
|
||||
})
|
||||
|
||||
if (!quests.some((quest) => quest.id === questId)) {
|
||||
toast.warning(t('game.language_unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
await sessionStore.createSession()
|
||||
} catch (error) {
|
||||
console.error('Failed to reset quest:', error)
|
||||
toast.error(t('game.reset_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const continuePlay = async () => {
|
||||
try {
|
||||
router.push({ name: 'map' })
|
||||
} catch (err) {
|
||||
useToast().error('無法讀取地圖')
|
||||
useToast().error(t('quest_map.load_quest_map_failed'))
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-2 flex flex-row gap-3 justify-center">
|
||||
<section class="mt-2 flex flex-row gap-2 justify-center">
|
||||
<button
|
||||
id="survey"
|
||||
:title="t('game_nav.survey')"
|
||||
class="bg-bg-secondary rounded-lg p-2"
|
||||
@click="openQuestSurvey"
|
||||
>
|
||||
@@ -31,15 +64,18 @@ const continuePlay = async () => {
|
||||
:icon="['fas', 'file-invoice']"
|
||||
class="text-text-primary px-1"
|
||||
/>
|
||||
<p class="text-text-primary inline md:flex">問卷</p>
|
||||
<p class="text-text-primary text-xs">{{ t('game_nav.survey') }}</p>
|
||||
</button>
|
||||
<button
|
||||
title="Bug Report"
|
||||
id="bug-report"
|
||||
:title="t('game_nav.bug_report')"
|
||||
@click="openBugReport"
|
||||
class="bg-bg-secondary rounded-lg p-2"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
|
||||
<p class="text-text-primary inline ml-1 md:flex">回報</p>
|
||||
<p class="text-text-primary text-xs">
|
||||
{{ t('game_nav.bug_report') }}
|
||||
</p>
|
||||
</button>
|
||||
<!-- <button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
|
||||
<font-awesome-icon
|
||||
@@ -48,15 +84,23 @@ const continuePlay = async () => {
|
||||
/>
|
||||
<p class="text-text-disabled inline ml-1 md:flex">解答</p>
|
||||
</button> -->
|
||||
<button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
|
||||
<button
|
||||
id="reset"
|
||||
:title="t('game.reset')"
|
||||
class="bg-bg-secondary rounded-lg p-2"
|
||||
@click="reset"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'arrow-rotate-left']"
|
||||
class="text-text-primary px-1"
|
||||
/>
|
||||
<p class="text-text-primary inline ml-1 md:flex">重來</p>
|
||||
<p class="text-text-primary text-xs">
|
||||
{{ t('game.reset') }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
id="continue"
|
||||
:title="t('game.continue')"
|
||||
class="bg-bg-secondary rounded-lg p-2"
|
||||
@click="continuePlay"
|
||||
>
|
||||
@@ -64,7 +108,9 @@ const continuePlay = async () => {
|
||||
:icon="['far', 'circle-right']"
|
||||
class="text-text-primary px-1"
|
||||
/>
|
||||
<p class="text-text-primary inline ml-1 md:flex">繼續</p>
|
||||
<p class="text-text-primary text-xs">
|
||||
{{ t('game.continue') }}
|
||||
</p>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import useSession from '../../store/session'
|
||||
import { useSession } from '../../store/session'
|
||||
import MarkdownText from '../MarkdownText.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sessionStore = useSession()
|
||||
const current = ref(-1)
|
||||
const hintDetails = ref<HTMLDetailsElement | null>(null)
|
||||
const length = computed(() => {
|
||||
return sessionStore.session?.responses.length ?? 0
|
||||
return sessionStore.session?.responses?.length ?? 0
|
||||
})
|
||||
|
||||
const disabled = computed(() => {
|
||||
@@ -14,6 +18,7 @@ const disabled = computed(() => {
|
||||
})
|
||||
|
||||
watch(length, () => {
|
||||
console.log('length update', length.value)
|
||||
current.value = length.value - 1
|
||||
})
|
||||
|
||||
@@ -28,6 +33,19 @@ const right = () => {
|
||||
current.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
const response = computed(() => {
|
||||
if (!sessionStore.session) return null
|
||||
if (current.value < 0) return null
|
||||
if (current.value >= sessionStore.session.responses.length) return null
|
||||
return sessionStore.session.responses[current.value]
|
||||
})
|
||||
|
||||
watch(response, () => {
|
||||
if (hintDetails.value) {
|
||||
hintDetails.value.open = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,7 +55,9 @@ const right = () => {
|
||||
:icon="['far', 'lightbulb']"
|
||||
class="text-yellow-200 p-2 content-center"
|
||||
/>
|
||||
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">說明</h1>
|
||||
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">
|
||||
{{ t('game.description') }}
|
||||
</h1>
|
||||
<div v-if="current !== -1" class="flex w-full justify-end items-end">
|
||||
<button
|
||||
class="p-2 m-1 w-1/8"
|
||||
@@ -62,27 +82,30 @@ const right = () => {
|
||||
@click="right"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'arrow-right']"
|
||||
:class="disabled ? 'text-gray-600' : 'text-text'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hint" class="bg-bg p-8 overflow-y-auto">
|
||||
<ul>
|
||||
<li
|
||||
v-for="response in sessionStore.session?.responses[current]"
|
||||
v-if="response"
|
||||
:key="response.type"
|
||||
class="text-text font-xl whitespace-pre-wrap"
|
||||
>
|
||||
<div v-for="content in response.content" :key="content">
|
||||
<MarkdownText :content="content" />
|
||||
</div>
|
||||
<MarkdownText :content="response.content" />
|
||||
</li>
|
||||
<li
|
||||
v-for="hint in sessionStore.session?.hints[current]"
|
||||
:key="hint"
|
||||
v-if="response?.hint"
|
||||
class="text-text-primary font-xl whitespace-pre-wrap"
|
||||
>
|
||||
<MarkdownText :content="hint" />
|
||||
<details ref="hintDetails">
|
||||
<summary class="cursor-pointer">{{ t('game.hint') }}</summary>
|
||||
<MarkdownText :content="response.hint" />
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import MarkdownText from '../MarkdownText.vue'
|
||||
import useSession from '../../store/session'
|
||||
import { Task } from '../../types'
|
||||
import { useSession } from '../../store/session'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sessionStore = useSession()
|
||||
const tasks = computed(() => {
|
||||
return sessionStore.session?.tasks.map((task: Task) => {
|
||||
console.log('session', sessionStore.session)
|
||||
return sessionStore.session?.tasks.map((task) => {
|
||||
const prefix = task.completed ? '✓' : '➤'
|
||||
const color = task.completed ? 'text-text-primary' : 'text-text'
|
||||
return {
|
||||
@@ -31,14 +34,14 @@ const tasks = computed(() => {
|
||||
:content="sessionStore.quest.instruction"
|
||||
/>
|
||||
<br />
|
||||
<p id="tasks" class="text-text">任務:</p>
|
||||
<p id="tasks" class="text-text font-bold">{{ t('game.task') }}</p>
|
||||
<ul v-if="sessionStore.session">
|
||||
<li v-for="task in tasks" :key="task.id">
|
||||
<MarkdownText :class="task.class" :content="task.content" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="text-text" v-else>Loading...</p>
|
||||
<p class="text-text" v-else>{{ t('game.loading') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { computed } from 'vue'
|
||||
import useSession from '../../store/session'
|
||||
import { useSession } from '../../store/session'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
const sessionStore = useSession()
|
||||
@@ -31,7 +34,7 @@ const startSession = async () => {
|
||||
:icon="['fas', 'play']"
|
||||
class="text-text-primary h-full px-3"
|
||||
/>
|
||||
<p class="text-text-primary inline ml-1 md:flex">開始</p>
|
||||
<p class="text-text-primary inline ml-1 md:flex">{{ t('game.start') }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useTerminal } from '../../store/session'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useSession, useTerminal } from '../../store/session'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sessionStore = useSession()
|
||||
|
||||
const terminal = ref<HTMLElement | null>(null)
|
||||
const term = useTerminal()
|
||||
|
||||
onMounted(() => {
|
||||
const term = useTerminal()
|
||||
if (!terminal.value) return
|
||||
term.mount(terminal.value)
|
||||
window.addEventListener('resize', () => term.resizeScreen())
|
||||
|
||||
const handleResize = () => term.resizeScreen()
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
|
||||
<button id="Terminal" class="text-text h-[5%]">終端機</button>
|
||||
<div
|
||||
id="terminal"
|
||||
ref="terminal"
|
||||
class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
|
||||
></div>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-center gap-2 p-1">
|
||||
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text" />
|
||||
<p id="Terminal" class="text-text">{{ t('game.terminal') }}</p>
|
||||
|
||||
<a
|
||||
class="text-xs font-normal text-gray-200 ml-10 bg-bg-primary rounded-lg px-2 py-1 hover:bg-bg-primary-hover transition-colors duration-300"
|
||||
:href="`/terminal/${sessionStore.session?.containerName}?token=${sessionStore.session?.token}`"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t('game.open_terminal_in_new_tab') }}
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
id="terminal"
|
||||
ref="terminal"
|
||||
class="bg-bg-secondary flex-grow overflow-hidden px-2"
|
||||
style="height: calc(100vh - 40px)"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import FileNode from '../FileNode.vue'
|
||||
import useSession from '../../store/session'
|
||||
import { useSession } from '../../store/session'
|
||||
|
||||
const sessionStore = useSession()
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { logout } from '../../utils/auth'
|
||||
import { surveyUrl } from '../../config'
|
||||
import { trpc } from '../../utils/trpc'
|
||||
import { openBugReport } from '../../utils/formbricks'
|
||||
import useSession from '../../store/session'
|
||||
import { useSession } from '../../store/session'
|
||||
import useUserProfile from '../../store/userProfile'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserProfile()
|
||||
const sessionStore = useSession()
|
||||
const router = useRouter()
|
||||
@@ -21,16 +23,21 @@ const handleLogout = async () => {
|
||||
sessionStore.reset()
|
||||
router.push({ name: 'login' })
|
||||
} catch (err) {
|
||||
toast.error('登出失敗')
|
||||
toast.error(t('game_nav.sign_out_failed'))
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const openSurvey = async () => {
|
||||
const surveyUrl = await trpc.links.surveyLink.query()
|
||||
window.open(surveyUrl, '_blank')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await userStore.loadUserProfile()
|
||||
} catch (err) {
|
||||
toast.error('無法讀取使用者資料')
|
||||
toast.error(t('game_nav.load_user_profile_failed'))
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
@@ -61,47 +68,46 @@ onMounted(async () => {
|
||||
>
|
||||
{{ userStore.username }}
|
||||
</p>
|
||||
<a
|
||||
title="Survey"
|
||||
:href="surveyUrl"
|
||||
<button
|
||||
:title="t('game_nav.survey')"
|
||||
target="_blank"
|
||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||
@click="openSurvey"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'file-invoice']"
|
||||
class="text-text-primary inline"
|
||||
/>
|
||||
<span class="text-text sm:hidden px-2">Survey</span>
|
||||
</a>
|
||||
</button>
|
||||
<RouterLink
|
||||
title="LeaderBoard"
|
||||
:title="t('game_nav.leaderboard')"
|
||||
to="/leaderboard"
|
||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'trophy']" class="text-text-primary" />
|
||||
<span class="text-text sm:hidden px-2">Leader Board</span>
|
||||
</RouterLink>
|
||||
<RouterLink title="Map" to="/map" class="md:h-5 md:w-5 px-1.5 w-auto">
|
||||
<RouterLink
|
||||
:title="t('game_nav.map')"
|
||||
to="/map"
|
||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
|
||||
<span class="text-text sm:hidden px-2">Map</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
title="Bug Report"
|
||||
:title="t('game_nav.bug_report')"
|
||||
@click="openBugReport"
|
||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
|
||||
<span class="text-text sm:hidden px-2">Bug Report</span>
|
||||
</button>
|
||||
<button
|
||||
title="Sign Out"
|
||||
:title="t('game_nav.sign_out')"
|
||||
@click="handleLogout"
|
||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||
class="md:h-5 md:w-5 px-1.5 mr-2 w-auto"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'arrow-right-from-bracket']"
|
||||
class="text-text-primary"
|
||||
/>
|
||||
<span class="text-text sm:hidden px-2">Sign Out</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,5 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import LanguageSelector from './LanguageSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
@@ -19,7 +24,7 @@ const toggleMenu = () => {
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="w-full top-0 px-5 fixed flex flex-row flex-wrap bg-bg-secondary justify-between items-center sm:justify-between"
|
||||
class="w-full top-0 px-2 fixed flex flex-row flex-wrap bg-bg-secondary justify-between items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-3" :class="{ hidden: menuOpen }">
|
||||
<img src="../../img/icon_totem.svg" class="h-8 justify-self-start" />
|
||||
@@ -44,6 +49,7 @@ const toggleMenu = () => {
|
||||
:class="{ hidden: !menuOpen }"
|
||||
>
|
||||
<component :is="headerComponent" />
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<div
|
||||
v-if="windowWidth"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { bugReportUrl } from '../../config'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
const section = document.getElementById(sectionId)
|
||||
if (section) {
|
||||
@@ -21,22 +24,25 @@ const scrollToSection = (sectionId: string) => {
|
||||
@click="scrollToSection('intro')"
|
||||
style="font-size: 2vh"
|
||||
>
|
||||
前言
|
||||
{{ t('landing_nav.intro') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="Feature"
|
||||
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
|
||||
@click="scrollToSection('feature')"
|
||||
style="font-size: 2vh"
|
||||
>
|
||||
特色</button
|
||||
><button
|
||||
{{ t('landing_nav.features') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="Info"
|
||||
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
|
||||
@click="scrollToSection('about')"
|
||||
style="font-size: 2vh"
|
||||
>
|
||||
關於
|
||||
{{ t('landing_nav.about') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -45,18 +51,27 @@ const scrollToSection = (sectionId: string) => {
|
||||
@click="scrollToSection('contact')"
|
||||
style="font-size: 2vh"
|
||||
>
|
||||
聯絡我們
|
||||
{{ t('landing_nav.contact') }}
|
||||
</button>
|
||||
|
||||
<RouterLink
|
||||
title="Sign In"
|
||||
id="SignIn"
|
||||
to="/register"
|
||||
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
|
||||
style="font-size: 2vh"
|
||||
>
|
||||
登入
|
||||
{{ t('landing_nav.login') }}
|
||||
</RouterLink>
|
||||
<a title="Bug Report" :href="bugReportUrl" target="_blank" class="h-5 w-5">
|
||||
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
|
||||
|
||||
<a
|
||||
:title="t('landing_nav.report_bug')"
|
||||
:href="bugReportUrl"
|
||||
target="_blank"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'bug']"
|
||||
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { QuestLocale } from '@odyssey/constants'
|
||||
import { SUPPORTED_LOCALES, LOCALE_NAME_MAP } from '@odyssey/constants'
|
||||
import { setLocale } from '../../i18n'
|
||||
|
||||
const { t, locale } = useI18n({ useScope: 'global' })
|
||||
|
||||
function changeLanguage(event: Event) {
|
||||
const selectedLocale = (event.target as HTMLSelectElement)
|
||||
.value as QuestLocale
|
||||
|
||||
setLocale(selectedLocale)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
:value="locale"
|
||||
:title="t('language.change')"
|
||||
class="bg-bg-secondary px-3 py-2 text-sm text-text shadow-md outline-none"
|
||||
aria-label="Language"
|
||||
@change="changeLanguage"
|
||||
style="font-size: 2vh"
|
||||
>
|
||||
<option v-for="locale in SUPPORTED_LOCALES" :key="locale" :value="locale">
|
||||
{{ LOCALE_NAME_MAP[locale] }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import type { QuestLocale } from '@odyssey/constants'
|
||||
import { SUPPORTED_LOCALES } from '@odyssey/constants'
|
||||
import en from './locales/en.json'
|
||||
import zhTW from './locales/zh-TW.json'
|
||||
|
||||
function isSupportedLocale(locale: string): locale is QuestLocale {
|
||||
return SUPPORTED_LOCALES.includes(locale as QuestLocale)
|
||||
}
|
||||
|
||||
function getInitialLocale(): QuestLocale {
|
||||
const savedLocale = localStorage.getItem('locale')
|
||||
|
||||
if (savedLocale && isSupportedLocale(savedLocale)) {
|
||||
return savedLocale
|
||||
}
|
||||
|
||||
return navigator.language.startsWith('zh') ? 'zh-TW' : 'en'
|
||||
}
|
||||
|
||||
const initialLocale = getInitialLocale()
|
||||
|
||||
document.documentElement.lang = initialLocale
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale,
|
||||
fallbackLocale: 'zh-TW',
|
||||
messages: {
|
||||
'zh-TW': zhTW,
|
||||
en,
|
||||
},
|
||||
})
|
||||
|
||||
export function setLocale(locale: QuestLocale) {
|
||||
i18n.global.locale.value = locale
|
||||
localStorage.setItem('locale', locale)
|
||||
document.documentElement.lang = locale
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"language": {
|
||||
"change": "Change language"
|
||||
},
|
||||
"landing_nav": {
|
||||
"intro": "Introduction",
|
||||
"features": "Features",
|
||||
"about": "About",
|
||||
"contact": "Contact",
|
||||
"login": "Sign in",
|
||||
"report_bug": "Report a bug"
|
||||
},
|
||||
"landing": {
|
||||
"intro": "Programming is about more than writing code - it also means knowing how to interact effectively with your computer. Yet programming education often overlooks the importance of the development environment, leaving many learners frustrated. A development environment includes all the tools needed to write and run software, many of which are used through terminal commands. While most learners use Windows or macOS, much of the software world runs on Linux. Linux Odyssey is an interactive online learning platform designed to bridge that gap.",
|
||||
"feature1": "Easy for Beginners",
|
||||
"feature1_desc": "Setting up and using development tools can be a major hurdle for beginners. With Linux Odyssey, there is nothing to install - learners can access a real Linux environment directly from their browser.",
|
||||
"feature2": "Learn Through Play",
|
||||
"feature2_desc": "Linux Odyssey combines the fun of gaming with practical learning. Through carefully designed levels, learners take on challenges, solve puzzles, and master Linux commands along the way.",
|
||||
"feature3": "Hands-on Linux Experience",
|
||||
"feature3_desc": "Linux Odyssey goes beyond theory by providing real hands-on experience. Every command runs in a real Linux environment, giving learners an authentic introduction to the world of software development.",
|
||||
"start_journey": "Grab your keyboard and begin your Linux adventure!",
|
||||
"team_intro": "Meet the Team",
|
||||
"team_desc": "NTUT Intelligent Automation Engineering, 2nd Cohort",
|
||||
"team_member1": "Wancat",
|
||||
"team_member2": "Neil",
|
||||
"team_member3": "A.H.",
|
||||
"team_member4": "Ray Gur",
|
||||
"awards": "Awards",
|
||||
"award_desc": "2023 InnoServe Awards - 2nd Place, Titansoft Agile Development Award",
|
||||
"player_feedback": "What Players Say",
|
||||
"feedback1": "\"It's really fun - just like a puzzle game.\"",
|
||||
"feedback2": "\"I never knew free software existed. Thank you!\"",
|
||||
"feedback3": "\"It's pretty fun. It makes programming enjoyable.\"",
|
||||
"feedback4": "\"I used to dislike computer class, but now I like it a little more.\"",
|
||||
"feedback5": "\"The program is really beginner-friendly, and I learned a lot.\"",
|
||||
"feedback6": "\"It's very well made, clear, and easy to understand. A lot of thought went into the level design.\"",
|
||||
"feedback7": "\"I found the experience really interesting. I got to try programming in a way I never had before, and the student mentors were very helpful. Thank you for explaining everything so carefully. I hope I can join an activity like this again.\"",
|
||||
"feedback8": "\"I used to think programming was difficult, but now I find it interesting.\"",
|
||||
"feedback9": "\"I used to think programming was difficult and had nothing to do with my life. After this class, it feels much more approachable.\"",
|
||||
"contact_us": "Contact Us"
|
||||
},
|
||||
"authform": {
|
||||
"google_login": "Continue with Google",
|
||||
"github_login": "Continue with GitHub",
|
||||
"or": "or",
|
||||
"username": "Username",
|
||||
"username_rules": "At most 32 characters, lowercase first, use lowercase letters, numbers, _, and -",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"already_registered": "Already have an account?",
|
||||
"not_registered": "Don't have an account?",
|
||||
"continue_as_guest": "Continue as Guest",
|
||||
"password_rules": "At least 8 characters, including uppercase and lowercase letters and numbers",
|
||||
"recommand_pc_fullscreen": "Recommended to use desktop version in full-screen mode"
|
||||
},
|
||||
"authform_error": {
|
||||
"too_many_requests": "Too many requests, please try again in 2 minutes",
|
||||
"unauthorized": "Invalid username or password",
|
||||
"auth_failed": "Something went wrong",
|
||||
"invalid_username": "Invalid username",
|
||||
"invalid_email": "Invalid email",
|
||||
"invalid_password": "Invalid password",
|
||||
"username_taken": "Username is already taken",
|
||||
"email_taken": "Email is already taken"
|
||||
},
|
||||
"game_nav": {
|
||||
"survey": "Survey",
|
||||
"leaderboard": "Leaderboard",
|
||||
"map": "Map",
|
||||
"bug_report": "Bug Report",
|
||||
"sign_out": "Sign Out",
|
||||
"sign_out_failed": "Sign Out Failed",
|
||||
"load_user_profile_failed": "Failed to Load User Profile"
|
||||
},
|
||||
"quest_map": {
|
||||
"title": "Quest Map",
|
||||
"intro": "Embark on your Linux adventure!",
|
||||
"load_quest_map_failed": "Failed to Load Quest Map",
|
||||
"compute_graph_failed": "Error computing graph data: ",
|
||||
"load_quest_data_failed": "Failed to Load Quest Data"
|
||||
},
|
||||
"quest": {
|
||||
"start": "Start Adventure",
|
||||
"locked": "Quest not unlocked",
|
||||
"load_quest_instruction_failed": "Failed to Load Quest Instruction: "
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "Leaderboard",
|
||||
"username": "Username",
|
||||
"score": "Score",
|
||||
"completed_quests": "Completed Quests",
|
||||
"load_leaderboard_failed": "Failed to Load Leaderboard"
|
||||
},
|
||||
"game": {
|
||||
"start": "Start",
|
||||
"task": "Task:",
|
||||
"loading": "Loading...",
|
||||
"description": "Description",
|
||||
"hint": "Hint",
|
||||
"terminal": "Terminal",
|
||||
"open_terminal_in_new_tab": "Open Terminal in New Tab",
|
||||
"reset": "Reset",
|
||||
"continue": "Continue",
|
||||
"command_book": "Command Book",
|
||||
"cheatsheets": "Cheat Sheets",
|
||||
"reset_to_apply_language": "Reset quest to apply language changes",
|
||||
"reset_failed": "Reset failed",
|
||||
"language_unavailable": "This quest does not support the selected language, please choose another language",
|
||||
"quest_completed": "Quest Completed",
|
||||
"quest_completed_desc_1": "Congratulations! You have completed this quest!",
|
||||
"quest_completed_desc_2": "Return to the map to face new challenges!",
|
||||
"back_to_map": "Back to Map"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"language": {
|
||||
"change": "切換語言"
|
||||
},
|
||||
"landing_nav": {
|
||||
"intro": "前言",
|
||||
"features": "特色",
|
||||
"about": "關於",
|
||||
"contact": "聯絡我們",
|
||||
"login": "登入",
|
||||
"report_bug": "回報錯誤"
|
||||
},
|
||||
"landing": {
|
||||
"intro": "程式設計不僅僅是寫程式碼,還涉及到如何有效地與你的電腦互動。程式教育卻往往忽略背後「開發環境」的重要性,使得許多學生在過程中充滿挫折。「開發環境」是寫程式、執行程式過程所需要的一切工具。而這些工具需要在終端機輸入指令來使用。大部分的程式要用來運行的環境,並非 Windows 或 Mac OS,而是 Linux 作業系統。 Linux Odyssey 即是為此設計的線上 Linux 互動教學網站。",
|
||||
"feature1": "初學者也能立即上手",
|
||||
"feature1_desc": "我們明白對於初學者來說,設定和使用開發工具可能是一大障礙。學習者無需安裝任何軟體,僅需通過瀏覽器即可連上一個真實的 Linux 環境。",
|
||||
"feature2": "遊戲般的學習體驗",
|
||||
"feature2_desc": "Linux Odyssey 融合了遊戲的樂趣與學習的實用性。透過精心設計的遊戲關卡,學習者可以一邊接受挑戰、破解謎題,一邊掌握 Linux 指令的運用。",
|
||||
"feature3": "真實的 Linux 操作之旅",
|
||||
"feature3_desc": "Linux Odyssey 提供的不僅是理論知識,更是真實的操作體驗。每一個指令都是在真實的 Linux 環境中執行,讓學習者能夠真正深入軟體開發的世界。",
|
||||
"start_journey": "準備好鍵盤,跟我們一起開啟你的 Linux 冒險吧!",
|
||||
"team_intro": "團隊介紹",
|
||||
"team_desc": "國立臺北科技大學 智慧自動化工程科 第二屆",
|
||||
"team_member1": "Wancat",
|
||||
"team_member2": "Neil",
|
||||
"team_member3": "A.H.",
|
||||
"team_member4": "Ray Gur",
|
||||
"awards": "獲獎紀錄",
|
||||
"award_desc": "2023大專校院資訊應用服務創新競賽 鈦坦敏捷開發特別獎 第二名",
|
||||
"player_feedback": "玩家感想",
|
||||
"feedback1": "\"很好玩很像益智遊戲\"",
|
||||
"feedback2": "\"我從來不知道自由軟體的存在,謝謝你們。\"",
|
||||
"feedback3": "\"還蠻好玩的,讓程式變好玩\"",
|
||||
"feedback4": "\"以前不喜歡電腦課現在覺得喜歡一點點\"",
|
||||
"feedback5": "\"程式真的對新手很友善,讓我學到了很多東西\"",
|
||||
"feedback6": "\"感覺做得很好很簡明易懂關卡設計很用心\"",
|
||||
"feedback7": "\"這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動\"",
|
||||
"feedback8": "\"以前我覺得寫程式很難不過現在覺得很有趣\"",
|
||||
"feedback9": "\"以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近\"",
|
||||
"contact_us": "聯絡我們"
|
||||
},
|
||||
"authform": {
|
||||
"google_login": "以 Google 繼續",
|
||||
"github_login": "以 GitHub 繼續",
|
||||
"or": "或",
|
||||
"username": "帳號名稱",
|
||||
"username_rules": "至多 32 字元,小寫字母開頭,可用小寫字母、數字、_ 和 -",
|
||||
"email": "電子郵件",
|
||||
"password": "密碼",
|
||||
"login": "登入",
|
||||
"register": "註冊",
|
||||
"already_registered": "已經有帳號了嗎?",
|
||||
"not_registered": "還沒有帳號嗎?",
|
||||
"continue_as_guest": "以訪客身分繼續",
|
||||
"password_rules": "至少 8 個字元,包含數字及大小寫字母",
|
||||
"recommand_pc_fullscreen": "建議使用電腦版全螢幕操作"
|
||||
},
|
||||
"authform_error": {
|
||||
"too_many_requests": "太多請求,兩分鐘後再試一次",
|
||||
"unauthorized": "無效的帳號名稱或密碼",
|
||||
"auth_failed": "出了點問題",
|
||||
"invalid_username": "無效的帳號名稱",
|
||||
"invalid_email": "無效的電子郵件",
|
||||
"invalid_password": "無效的密碼",
|
||||
"username_taken": "帳號名稱已被使用",
|
||||
"email_taken": "電子郵件已被使用"
|
||||
},
|
||||
"game_nav": {
|
||||
"survey": "問卷",
|
||||
"leaderboard": "排行榜",
|
||||
"map": "地圖",
|
||||
"bug_report": "回報錯誤",
|
||||
"sign_out": "登出",
|
||||
"sign_out_failed": "登出失敗",
|
||||
"load_user_profile_failed": "無法讀取使用者資料"
|
||||
},
|
||||
"quest_map": {
|
||||
"title": "關卡地圖",
|
||||
"intro": "踏上你的Linux冒險之旅吧!",
|
||||
"load_quest_map_failed": "無法讀取關卡地圖",
|
||||
"compute_graph_failed": "計算關卡地圖時出錯:",
|
||||
"load_quest_data_failed": "無法讀取關卡"
|
||||
},
|
||||
"quest": {
|
||||
"start": "開始冒險",
|
||||
"locked": "關卡尚未解鎖",
|
||||
"load_quest_instruction_failed": "無法讀取關卡說明:"
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "排行榜",
|
||||
"username": "使用者名稱",
|
||||
"score": "分數",
|
||||
"completed_quests": "完成關卡",
|
||||
"load_leaderboard_failed": "無法讀取排行榜"
|
||||
},
|
||||
"game": {
|
||||
"start": "開始",
|
||||
"task": "任務:",
|
||||
"loading": "載入中...",
|
||||
"description": "說明",
|
||||
"hint": "提示",
|
||||
"terminal": "終端機",
|
||||
"open_terminal_in_new_tab": "在新分頁開啟終端機",
|
||||
"reset": "重置",
|
||||
"continue": "繼續",
|
||||
"command_book": "指令之書",
|
||||
"cheatsheets": "指令列表",
|
||||
"reset_to_apply_language": "重置關卡以套用語言變更",
|
||||
"reset_failed": "重置失敗",
|
||||
"language_unavailable": "此關卡不支援所選語言,請選擇其他語言",
|
||||
"quest_completed": "關卡完成",
|
||||
"quest_completed_desc_1": "恭喜你完成了這個關卡!",
|
||||
"quest_completed_desc_2": "回到地圖,迎接新的挑戰吧!",
|
||||
"back_to_map": "回到地圖"
|
||||
}
|
||||
}
|
||||
+7
-9
@@ -1,18 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import './style.css'
|
||||
/* import font awesome icon component */
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import './library'
|
||||
/* import Vue Toastification */
|
||||
import Toast, { POSITION } from 'vue-toastification'
|
||||
/* import Vue Lottie */
|
||||
import Vue3Lottie from 'vue3-lottie'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' // import font awesome icon component
|
||||
import { i18n } from './i18n'
|
||||
import Toast, { POSITION } from 'vue-toastification' // import Vue Toastification
|
||||
import Vue3Lottie from 'vue3-lottie' // import Vue Lottie
|
||||
import 'vue-toastification/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import formbricks from './utils/formbricks'
|
||||
import './style.css'
|
||||
import './library'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
@@ -21,6 +18,7 @@ app.component('font-awesome-icon', FontAwesomeIcon)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
router.afterEach(() => {
|
||||
if (typeof formbricks !== 'undefined') {
|
||||
|
||||
+78
-51
@@ -1,75 +1,100 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
|
||||
import { IQuest } from '@linux-odyssey/models'
|
||||
import { createSession, getActiveSession, getQuest } from '../utils/api'
|
||||
import type { QuestLocale } from '@odyssey/constants'
|
||||
import { FileGraph, type FileGraphUpdateEvent } from '@odyssey/file-graph'
|
||||
import type { IResponse, ITask } from '@odyssey/game'
|
||||
import type { SessionDetail } from '../../../server/src/routers/sessionRouter'
|
||||
import Socket from '../utils/socket'
|
||||
import SocketTerminal from '../utils/terminal'
|
||||
import { Session, StageResponse } from '../types'
|
||||
import type { Session } from '../types'
|
||||
import { trpc } from '../utils/trpc'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const socket = new Socket()
|
||||
const term = new SocketTerminal(40, 80)
|
||||
const term = new SocketTerminal()
|
||||
let hasSetup = false
|
||||
|
||||
interface QuestDetailResponse {
|
||||
id: string
|
||||
title: string
|
||||
instruction: string
|
||||
requirements: string[]
|
||||
}
|
||||
|
||||
interface SessionUpdate {
|
||||
status: string
|
||||
responses: IResponse[]
|
||||
tasks: ITask[]
|
||||
graphUpdate: FileGraphUpdateEvent
|
||||
}
|
||||
|
||||
interface Store {
|
||||
session: Session | null
|
||||
questId: string
|
||||
quest: IQuest | null
|
||||
quest: QuestDetailResponse | null
|
||||
}
|
||||
|
||||
const useSession = defineStore('session', {
|
||||
export const useSession = defineStore('session', {
|
||||
state: (): Store => ({
|
||||
session: null,
|
||||
questId: '',
|
||||
quest: null,
|
||||
}),
|
||||
actions: {
|
||||
async setQuest(questId: string) {
|
||||
this.quest = await getQuest(questId)
|
||||
async setQuest(questId: string, locale: QuestLocale) {
|
||||
this.quest = await trpc.quests.getQuestDetail.query({
|
||||
questId,
|
||||
locale,
|
||||
})
|
||||
this.questId = questId
|
||||
},
|
||||
async setSession(session: Session) {
|
||||
this.session = session
|
||||
this.session.graph = new FileGraph(session.graph)
|
||||
async setSession(session: SessionDetail) {
|
||||
await this.setQuest(session.quest, session.locale)
|
||||
|
||||
this.session = {
|
||||
...session,
|
||||
graph: new FileGraph(session.graph),
|
||||
pwd: '/home/commander',
|
||||
containerName: session.containerName || '',
|
||||
}
|
||||
term.reset()
|
||||
await socket.connect(session)
|
||||
await socket.connect(this.session)
|
||||
await term.connect(
|
||||
`/terminal/${session.containerName}/ws?token=${session.token}`
|
||||
)
|
||||
term.focus()
|
||||
},
|
||||
async createSession() {
|
||||
const session = await createSession(this.questId)
|
||||
const locale = i18n.global.locale.value
|
||||
const session = await trpc.session.createSession.mutate({
|
||||
questId: this.questId,
|
||||
locale,
|
||||
})
|
||||
await this.setSession(session)
|
||||
socket.emit('terminal', 'echo start\n')
|
||||
term.send('echo start\n')
|
||||
},
|
||||
async getActiveSession() {
|
||||
const session = await getActiveSession(this.questId)
|
||||
async getActiveSession(questId: string) {
|
||||
const session = await trpc.session.getActiveSession.query({ questId })
|
||||
if (session) {
|
||||
this.setSession(session)
|
||||
await this.setSession(session)
|
||||
}
|
||||
},
|
||||
updateGraph(event: { discover?: FileObject[]; pwd?: string }) {
|
||||
newUpdate(update: SessionUpdate) {
|
||||
if (!this.session) return
|
||||
if (event.discover) {
|
||||
this.session.graph.discover(event.discover)
|
||||
}
|
||||
if (event.pwd) {
|
||||
this.session.pwd = event.pwd
|
||||
}
|
||||
this.session.responses = update.responses
|
||||
this.session.tasks = update.tasks
|
||||
this.session.status = update.status
|
||||
// if (
|
||||
// response.status === 'finished' &&
|
||||
// this.session.status !== 'finished'
|
||||
// ) {
|
||||
// this.finish()
|
||||
// }
|
||||
// this.session.status = response.status
|
||||
},
|
||||
newResponse(response: StageResponse) {
|
||||
finish() {
|
||||
if (!this.session) return
|
||||
this.session.responses.push(response.responses)
|
||||
this.session.hints.push(response.hints)
|
||||
if (response.tasks) {
|
||||
this.session.tasks = response.tasks
|
||||
}
|
||||
if (
|
||||
response.status === 'finished' &&
|
||||
this.session.status !== 'finished'
|
||||
) {
|
||||
this.finish()
|
||||
}
|
||||
this.session.status = response.status
|
||||
this.session.status = 'finished'
|
||||
},
|
||||
finish() {},
|
||||
reset() {
|
||||
socket.reset()
|
||||
term.reset()
|
||||
@@ -77,17 +102,21 @@ const useSession = defineStore('session', {
|
||||
},
|
||||
setup() {
|
||||
if (hasSetup) return
|
||||
socket.on('terminal', (data: string) => {
|
||||
term.write(data)
|
||||
// socket.on('terminal', (data: string) => {
|
||||
// term.write(data)
|
||||
// })
|
||||
// term.onData((data: string) => {
|
||||
// socket.emit('terminal', data)
|
||||
// })
|
||||
socket.on('graph', (event: FileGraphUpdateEvent) => {
|
||||
if (!this.session) return
|
||||
this.session.graph.handleEvent(event)
|
||||
if (event.pwd) {
|
||||
this.session.pwd = event.pwd
|
||||
}
|
||||
})
|
||||
term.onData((data: string) => {
|
||||
socket.emit('terminal', data)
|
||||
})
|
||||
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
|
||||
this.updateGraph(event)
|
||||
})
|
||||
socket.on('response', (response: StageResponse) => {
|
||||
this.newResponse(response)
|
||||
socket.on('update', (update: SessionUpdate) => {
|
||||
this.newUpdate(update)
|
||||
})
|
||||
hasSetup = true
|
||||
},
|
||||
@@ -101,5 +130,3 @@ const useSession = defineStore('session', {
|
||||
export function useTerminal() {
|
||||
return term
|
||||
}
|
||||
|
||||
export default useSession
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getUserProfile } from '../utils/api'
|
||||
import { trpc } from '../utils/trpc'
|
||||
|
||||
interface IProgress {
|
||||
quest: string
|
||||
@@ -23,7 +23,7 @@ const useUserProfile = defineStore('userProfile', {
|
||||
}),
|
||||
actions: {
|
||||
async loadUserProfile() {
|
||||
const res = await getUserProfile()
|
||||
const res = await trpc.users.getMyProfile.query()
|
||||
this.$patch(res)
|
||||
},
|
||||
async resetUserProfile() {
|
||||
|
||||
+6
-6
@@ -1,6 +1,5 @@
|
||||
import { FileGraph } from '@linux-odyssey/file-graph'
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import { IResponse } from '@linux-odyssey/models'
|
||||
import type { FileGraph } from '@odyssey/file-graph'
|
||||
import type { IResponse, ITask } from '@odyssey/game'
|
||||
|
||||
export interface Task {
|
||||
id: number
|
||||
@@ -20,7 +19,8 @@ export interface Session {
|
||||
status: string
|
||||
graph: FileGraph
|
||||
pwd: string
|
||||
hints: string[][]
|
||||
tasks: Task[]
|
||||
responses: IResponse[][]
|
||||
tasks: ITask[]
|
||||
responses: IResponse[]
|
||||
token: string
|
||||
containerName: string
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.css' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import { IQuest } from '@linux-odyssey/models'
|
||||
import { GetSessionsRequest } from '@linux-odyssey/constants'
|
||||
import { Session } from '../types'
|
||||
import { UserProfile } from '../store/userProfile'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
@@ -12,39 +8,3 @@ const api = axios.create({
|
||||
withCredentials: true,
|
||||
})
|
||||
export default api
|
||||
|
||||
export async function getUserProfile(): Promise<UserProfile> {
|
||||
const res = await api.get<UserProfile>('/users/me')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getQuest(id: string): Promise<IQuest> {
|
||||
const res = await api.get<IQuest>(`/quests/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createSession(questId: string): Promise<Session> {
|
||||
const res = await api.post<Session>('/sessions', { questId })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getSessions(
|
||||
request: GetSessionsRequest
|
||||
): Promise<Session[]> {
|
||||
const res = await api.get<Session[]>('/sessions', { params: request })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getActiveSession(
|
||||
questId: string
|
||||
): Promise<Session | null> {
|
||||
const res = await api.get<Session | null>('/sessions/active', {
|
||||
params: { questId },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getQuests(): Promise<IQuest[]> {
|
||||
const res = await api.get<IQuest[]>('/quests')
|
||||
return res.data
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import api from './api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
import {
|
||||
TooManyRequestsError,
|
||||
@@ -32,11 +33,15 @@ function handleRequestError(err: {
|
||||
const { path, msg } = errors[0]
|
||||
switch (path) {
|
||||
case 'username':
|
||||
throw new ValidationError('Username is already taken')
|
||||
throw new ValidationError(
|
||||
i18n.global.t('authform_error.username_taken')
|
||||
)
|
||||
case 'email':
|
||||
throw new ValidationError('Email is already taken')
|
||||
throw new ValidationError(i18n.global.t('authform_error.email_taken'))
|
||||
case 'password':
|
||||
throw new ValidationError('Password is too weak')
|
||||
throw new ValidationError(
|
||||
i18n.global.t('authform_error.invalid_password')
|
||||
)
|
||||
default:
|
||||
throw new ValidationError(msg)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import formbricks from '@formbricks/js/website'
|
||||
import useSession from '../store/session'
|
||||
import { useSession } from '../store/session'
|
||||
import useUserProfile from '../store/userProfile'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { Session } from '../types'
|
||||
import type { Session } from '../types'
|
||||
|
||||
type SocketCallback = (data: any) => void
|
||||
|
||||
@@ -23,11 +23,9 @@ class SocketWrapper {
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Socket connected to session', session._id)
|
||||
})
|
||||
|
||||
this.socket.once('terminal', () => {
|
||||
resolve()
|
||||
})
|
||||
|
||||
this.socket.on('connect_error', reject)
|
||||
this.bindListeners()
|
||||
})
|
||||
|
||||
+59
-23
@@ -1,47 +1,72 @@
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
class SocketTerminal {
|
||||
private term: Terminal
|
||||
private fitAddon: FitAddon
|
||||
private socket: ReconnectingWebSocket | null = null
|
||||
|
||||
constructor(rows: number, cols: number) {
|
||||
constructor() {
|
||||
this.term = new Terminal({
|
||||
rows, // 行数
|
||||
cols, // 不指定行数,自动回车后光标从下一行开始
|
||||
convertEol: true, // 启用时,光标将设置为下一行的开头
|
||||
// scrollback: 50, //终端中的回滚量
|
||||
disableStdin: false, // 是否应禁用输入
|
||||
// cursorStyle: "underline", //光标样式
|
||||
cursorBlink: true, // 光标闪烁
|
||||
convertEol: true,
|
||||
scrollback: 1000,
|
||||
fontSize: 16,
|
||||
theme: {
|
||||
foreground: '#ECECEC', // 字体
|
||||
background: '#000000', // 背景色
|
||||
cursor: 'help', // 设置光标
|
||||
foreground: '#ECECEC',
|
||||
background: '#000000',
|
||||
cursor: 'help',
|
||||
},
|
||||
fontSize: 18,
|
||||
fontWeight: 'normal',
|
||||
})
|
||||
this.fitAddon = new FitAddon()
|
||||
this.term.loadAddon(this.fitAddon)
|
||||
this.fitAddon.fit()
|
||||
this.term.onData((data) => {
|
||||
if (this.socket) {
|
||||
this.socket.send(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
write(message: string): void {
|
||||
this.term.write(message)
|
||||
}
|
||||
connect(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('Connecting to terminal', url)
|
||||
this.term.write('\r\n\x1B[1;32mConnecting to terminal\x1B[0m\r\n')
|
||||
this.socket = new ReconnectingWebSocket(url, [], {
|
||||
connectionTimeout: 5000,
|
||||
maxRetries: 10,
|
||||
})
|
||||
this.socket.onopen = () => {
|
||||
console.log('Connected to terminal')
|
||||
this.term.reset()
|
||||
this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n')
|
||||
resolve()
|
||||
}
|
||||
|
||||
onData(callback: any): void {
|
||||
this.term.onData(callback)
|
||||
}
|
||||
this.socket.onclose = () => {
|
||||
console.log('Disconnected from terminal')
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.term.focus()
|
||||
this.socket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
this.socket.onmessage = (event) => {
|
||||
console.log('Received message from terminal')
|
||||
const data =
|
||||
typeof event.data === 'string'
|
||||
? event.data
|
||||
: new TextDecoder().decode(event.data)
|
||||
this.term.write(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
resizeScreen(): void {
|
||||
this.fitAddon.fit()
|
||||
try {
|
||||
this.fitAddon.fit()
|
||||
} catch (e) {
|
||||
console.error('Failed to resize terminal:', e)
|
||||
}
|
||||
}
|
||||
|
||||
mount(reference: HTMLElement): void {
|
||||
@@ -49,8 +74,19 @@ class SocketTerminal {
|
||||
this.resizeScreen()
|
||||
}
|
||||
|
||||
send(message: string): void {
|
||||
if (this.socket) {
|
||||
this.socket.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.term.focus()
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.term.reset()
|
||||
this.socket?.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client'
|
||||
import type { AppRouter } from '../../../server/src/routers'
|
||||
|
||||
export const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: '/trpc',
|
||||
// You can pass any HTTP headers you wish here
|
||||
// async headers() {
|
||||
// return {
|
||||
// authorization: getAuthCookie(),
|
||||
// }
|
||||
// },
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { isValidUsername } from '@linux-odyssey/utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isValidUsername } from '@odyssey/utils'
|
||||
import AuthForm from '../components/AuthForm.vue'
|
||||
import Background from '../components/DynamicBackground.vue'
|
||||
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
import { TooManyRequestsError, ValidationError } from '../utils/errors'
|
||||
import { checkUsername, chooseUsername } from '../utils/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
async function check({
|
||||
username,
|
||||
error,
|
||||
@@ -15,21 +19,21 @@ async function check({
|
||||
}) {
|
||||
if (username) {
|
||||
if (!isValidUsername(username)) {
|
||||
error('無效的帳號名稱')
|
||||
error(t('authform_error.invalid_username'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await checkUsername(username)
|
||||
} catch (err) {
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('太多請求,兩分鐘後再試一次。')
|
||||
error(t('authform_error.too_many_requests'))
|
||||
return
|
||||
}
|
||||
if (err instanceof ValidationError) {
|
||||
error(err.message)
|
||||
return
|
||||
}
|
||||
error('出了點問題,請再試一次。')
|
||||
error(t('authform_error.auth_failed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +53,7 @@ async function handleSubmit({
|
||||
success()
|
||||
} catch (err: any) {
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('太多請求,兩分鐘後再試一次。')
|
||||
error(t('authform_error.too_many_requests'))
|
||||
return
|
||||
}
|
||||
if (err instanceof ValidationError) {
|
||||
@@ -63,10 +67,11 @@ async function handleSubmit({
|
||||
|
||||
<template>
|
||||
<div class="w-screen h-screen">
|
||||
<Background class="w-full h-full" />
|
||||
<DynamicBackground class="w-full h-full" />
|
||||
<div
|
||||
class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
|
||||
>
|
||||
<HeaderPart />
|
||||
<div class="w-fit">
|
||||
<AuthForm
|
||||
@onSubmit="handleSubmit"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import useSession from '../store/session'
|
||||
import { useSession } from '../store/session'
|
||||
import useUserProfile from '../store/userProfile'
|
||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||
@@ -13,6 +15,8 @@ import CompleteModal from '../components/game/CompleteModal.vue'
|
||||
import StartButton from '../components/game/StartButton.vue'
|
||||
import { openQuestSurvey } from '../utils/formbricks'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const toast = useToast()
|
||||
const sessionStore = useSession()
|
||||
const userStore = useUserProfile()
|
||||
|
||||
@@ -27,6 +31,13 @@ watch(completed, (newValue, oldValue) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(locale, (newLocale) => {
|
||||
const { session } = sessionStore
|
||||
if (session && newLocale !== session.locale) {
|
||||
toast.info(t('game.reset_to_apply_language'))
|
||||
}
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
questId: {
|
||||
type: String,
|
||||
@@ -37,8 +48,12 @@ const props = defineProps({
|
||||
onMounted(async () => {
|
||||
sessionStore.reset()
|
||||
sessionStore.setup()
|
||||
await sessionStore.setQuest(props.questId)
|
||||
await sessionStore.getActiveSession()
|
||||
|
||||
await sessionStore.getActiveSession(props.questId)
|
||||
|
||||
if (!sessionStore.session) {
|
||||
await sessionStore.setQuest(props.questId, locale.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ContactButton from '../components/ContactButton.vue'
|
||||
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||
import LandingHeaderComponents from '../components/header/LandingHeaderComponents.vue'
|
||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
|
||||
import { facebookUrl, githubUrl } from '../config'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<div class="w-screen h-screen bg-scroll bg-bg">
|
||||
@@ -18,13 +21,9 @@ import { facebookUrl, githubUrl } from '../config'
|
||||
<div
|
||||
class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10"
|
||||
>
|
||||
<h1 class="text-text-primary text-7xl font-extrabold font-mono">
|
||||
Linux
|
||||
</h1>
|
||||
<h1 class="text-text-primary text-7xl font-extrabold">Linux</h1>
|
||||
<img src="../img/icon_totem.svg" class="h-[30vh]" />
|
||||
<h1 class="text-text-primary text-7xl font-extrabold font-mono">
|
||||
Odyssey
|
||||
</h1>
|
||||
<h1 class="text-text-primary text-7xl font-extrabold">Odyssey</h1>
|
||||
</div>
|
||||
<!-- intro -->
|
||||
<div class="w-full md:w-2/3 px-5">
|
||||
@@ -36,13 +35,7 @@ import { facebookUrl, githubUrl } from '../config'
|
||||
class="text-text inline-block bg-bg-primary p-5 rounded-xl opacity-90"
|
||||
style="font-size: 2.5vh"
|
||||
>
|
||||
程式設計不僅僅是寫程式碼,還涉及到如何有效地與你的電腦互動。
|
||||
程式教育卻往往忽略背後「開發環境」的重要性,
|
||||
使得許多學生在過程中充滿挫折。
|
||||
「開發環境」是寫程式、執行程式過程所需要的一切工具。
|
||||
而這些工具需要在終端機輸入指令來使用。
|
||||
大部分的程式要用來運行的環境,並非 Windows 或 Mac OS,而是 Linux
|
||||
作業系統。 Linux Odyssey 即是為此設計的線上 Linux 互動教學網站。
|
||||
{{ t('landing.intro') }}
|
||||
</h1>
|
||||
</section>
|
||||
<section
|
||||
@@ -57,31 +50,27 @@ import { facebookUrl, githubUrl } from '../config'
|
||||
class="h-[18vh] row-span-2"
|
||||
/>
|
||||
<h1
|
||||
class="text-text-primary p-5 font-extrabold font-mono col-span-2"
|
||||
class="text-text-primary p-5 font-extrabold col-span-2"
|
||||
style="font-size: 3.5vh"
|
||||
>
|
||||
初學者也能立即上手
|
||||
{{ t('landing.feature1') }}
|
||||
</h1>
|
||||
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
|
||||
我們明白對於初學者來說,設定和使用開發工具可能是一大障礙。
|
||||
學習者無需安裝任何軟體,僅需通過瀏覽器即可連上一個真實的 Linux
|
||||
環境。
|
||||
{{ t('landing.feature1_desc') }}
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="bg-bg-primary p-5 rounded-xl opacity-90 grid grid-cols-3 grid-rows-2 justify-items-center items-center"
|
||||
>
|
||||
<h1
|
||||
class="text-text-primary p-5 font-extrabold font-mono col-span-2"
|
||||
class="text-text-primary p-5 font-extrabold col-span-2"
|
||||
style="font-size: 3.5vh"
|
||||
>
|
||||
遊戲般的學習體驗
|
||||
{{ t('landing.feature2') }}
|
||||
</h1>
|
||||
<img src="../img/icon_sword.svg" class="h-[18vh] row-span-2" />
|
||||
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
|
||||
Linux Odyssey 融合了遊戲的樂趣與學習的實用性。
|
||||
透過精心設計的遊戲關卡,學習者可以一邊接受挑戰、破解謎題,
|
||||
一邊掌握 Linux 指令的運用。
|
||||
{{ t('landing.feature2_desc') }}
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
@@ -92,15 +81,13 @@ import { facebookUrl, githubUrl } from '../config'
|
||||
class="h-[18vh] row-span-2"
|
||||
/>
|
||||
<h1
|
||||
class="text-text-primary p-5 font-extrabold font-mono col-span-2 pt-8"
|
||||
class="text-text-primary p-5 font-extrabold col-span-2 pt-8"
|
||||
style="font-size: 3.5vh"
|
||||
>
|
||||
真實的 Linux 操作之旅
|
||||
{{ t('landing.feature3') }}
|
||||
</h1>
|
||||
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
|
||||
Linux Odyssey 提供的不僅是理論知識,更是真實的操作體驗。
|
||||
每一個指令都是在真實的 Linux 環境中執行,
|
||||
讓學習者能夠真正深入軟體開發的世界。
|
||||
{{ t('landing.feature3_desc') }}
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
@@ -120,7 +107,7 @@ import { facebookUrl, githubUrl } from '../config'
|
||||
>
|
||||
<RouterLink title="StartGame" id="StartGame" to="/register">
|
||||
<div class="text-text text-xl p-2 w-full text-center">
|
||||
準備好鍵盤,跟我們一起開啟你的 Linux 冒險吧!
|
||||
{{ t('landing.start_journey') }}
|
||||
</div>
|
||||
</RouterLink>
|
||||
</section>
|
||||
@@ -129,24 +116,25 @@ import { facebookUrl, githubUrl } from '../config'
|
||||
class="text-text flex flex-col items-center mt-10 gap-10"
|
||||
>
|
||||
<div class="w-full flex flex-col items-center gap-2">
|
||||
<h1 class="text-text-primary text-3xl font-extrabold font-mono">
|
||||
團隊介紹
|
||||
<h1 class="text-text-primary text-3xl font-extrabold">
|
||||
{{ t('landing.team_intro') }}
|
||||
</h1>
|
||||
<p class="text-lg">國立臺北科技大學 智慧自動化工程科 第二屆</p>
|
||||
<p class="text-lg">
|
||||
{{ t('landing.team_desc') }}
|
||||
</p>
|
||||
<div class="flex flex-row gap-5 justify-center">
|
||||
<h1 class="font-bold">Wancat</h1>
|
||||
<h1 class="font-bold">Neil</h1>
|
||||
<h1 class="font-bold">A.H.</h1>
|
||||
<h1 class="font-bold">Ray Gur</h1>
|
||||
<h1 class="font-bold">{{ t('landing.team_member1') }}</h1>
|
||||
<h1 class="font-bold">{{ t('landing.team_member2') }}</h1>
|
||||
<h1 class="font-bold">{{ t('landing.team_member3') }}</h1>
|
||||
<h1 class="font-bold">{{ t('landing.team_member4') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex flex-col items-center gap-2">
|
||||
<h1 class="text-text-primary text-3xl font-extrabold font-mono">
|
||||
得獎肯定
|
||||
<h1 class="text-text-primary text-3xl font-extrabold">
|
||||
{{ t('landing.awards') }}
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
大專校院資訊應用服務創新競賽<br />
|
||||
鈦坦敏捷開發特別獎 第二名
|
||||
{{ t('landing.award_desc') }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="container mx-auto">
|
||||
@@ -154,35 +142,37 @@ import { facebookUrl, githubUrl } from '../config'
|
||||
class="justify-items-center grid grid-cols-1 sm:grid-cols-3 gap-5"
|
||||
>
|
||||
<h1
|
||||
class="text-text-primary text-3xl font-extrabold font-mono sm:col-span-3 gap-6"
|
||||
class="text-text-primary text-3xl font-extrabold sm:col-span-3 gap-6"
|
||||
>
|
||||
玩家感想
|
||||
{{ t('landing.player_feedback') }}
|
||||
<!--可以做成翻頁卡牌一次一個-->
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">"很好玩很像益智遊戲"</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"我從來不知道自由軟體的存在,謝謝你們。"
|
||||
{{ t('landing.feedback1') }}
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"還蠻好玩的,讓程式變好玩"
|
||||
{{ t('landing.feedback2') }}
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"以前不喜歡電腦課現在覺得喜歡一點點"
|
||||
{{ t('landing.feedback3') }}
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"程式真的對新手很友善,讓我學到了很多東西"
|
||||
{{ t('landing.feedback4') }}
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"感覺做得很好很簡明易懂關卡設計很用心"
|
||||
{{ t('landing.feedback5') }}
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動"
|
||||
{{ t('landing.feedback6') }}
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"以前我覺得寫程式很難不過現在覺得很有趣"
|
||||
{{ t('landing.feedback7') }}
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近"
|
||||
{{ t('landing.feedback8') }}
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
{{ t('landing.feedback9') }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,8 +182,8 @@ import { facebookUrl, githubUrl } from '../config'
|
||||
id="contact"
|
||||
class="text-text mt-10 flex flex-col items-center"
|
||||
>
|
||||
<h1 class="text-text-primary font-extrabold font-mono text-3xl">
|
||||
聯絡我們
|
||||
<h1 class="text-text-primary font-extrabold text-3xl">
|
||||
{{ t('landing.contact_us') }}
|
||||
</h1>
|
||||
<div
|
||||
class="flex flex-row items-center justify-around mt-5 gap-5 mb-5"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||
@@ -6,6 +7,8 @@ import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||
import api from '../utils/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const leaderboard = ref<
|
||||
{
|
||||
username: string
|
||||
@@ -21,7 +24,7 @@ async function getLeaderboard() {
|
||||
leaderboard.value = res.data
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
toast.error('讀取排行榜失敗')
|
||||
toast.error(t('leaderboard.load_leaderboard_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,22 +40,33 @@ onMounted(async () => {
|
||||
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
|
||||
>
|
||||
<div class="w-full">
|
||||
<HeaderPart title="排行榜" :headerComponent="GameHeaderComponents" />
|
||||
<HeaderPart
|
||||
:title="t('leaderboard.title')"
|
||||
:headerComponent="GameHeaderComponents"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-fit h-[94vh] m-3">
|
||||
<div
|
||||
class="h-full bg-bg border-8 border-bg-primary flex flex-col items-center rounded-3xl p-10"
|
||||
>
|
||||
<h1 class="text-text-primary text-3xl mb-5">排行榜</h1>
|
||||
<h1 class="text-text-primary text-3xl mb-5">
|
||||
{{ t('leaderboard.title') }}
|
||||
</h1>
|
||||
<div class="max-h-300 overflow-auto">
|
||||
<table class="w-full text-sm text-left text-text">
|
||||
<thead
|
||||
class="text-xs text-text-secondary uppercase bg-bg-secondary"
|
||||
>
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">使用者名稱</th>
|
||||
<th scope="col" class="px-6 py-3">分數</th>
|
||||
<th scope="col" class="px-6 py-3">完成關卡</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
{{ t('leaderboard.username') }}
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
{{ t('leaderboard.score') }}
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
{{ t('leaderboard.completed_quests') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AuthForm from '../components/AuthForm.vue'
|
||||
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
} from '../utils/errors'
|
||||
import { login } from '../utils/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const handleLogin = async ({
|
||||
username,
|
||||
password,
|
||||
@@ -27,19 +30,19 @@ const handleLogin = async ({
|
||||
if (isSuccess) success()
|
||||
} catch (err) {
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('太多請求,兩分鐘後再試一次。')
|
||||
error(t('authform_error.too_many_requests'))
|
||||
return
|
||||
}
|
||||
if (err instanceof UnauthorizedError) {
|
||||
error('錯誤的帳號名稱或密碼。')
|
||||
error(t('authform_error.unauthorized'))
|
||||
return
|
||||
}
|
||||
if (err instanceof ValidationError) {
|
||||
error('無效的帳號名稱或密碼。')
|
||||
error(err.message)
|
||||
return
|
||||
}
|
||||
console.error(err)
|
||||
error('出了點問題。')
|
||||
error(t('authform_error.auth_failed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import QuestMap from '../components/QuestMap.vue'
|
||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-fit">
|
||||
<HeaderPart
|
||||
title="關卡地圖"
|
||||
:title="t('quest_map.title')"
|
||||
:headerComponent="GameHeaderComponents"
|
||||
class="z-50"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import validator from 'validator'
|
||||
import { isValidUsername } from '@linux-odyssey/utils'
|
||||
import { passwordPolicy } from '@linux-odyssey/constants'
|
||||
import { isValidUsername } from '@odyssey/utils'
|
||||
import { passwordPolicy } from '@odyssey/constants'
|
||||
import AuthForm from '../components/AuthForm.vue'
|
||||
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
ValidationError,
|
||||
} from '../utils/errors'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleRegister({
|
||||
username,
|
||||
email,
|
||||
@@ -30,11 +33,11 @@ function handleRegister({
|
||||
.then(success)
|
||||
.catch((err) => {
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('太多請求,兩分鐘後再試一次。')
|
||||
error(t('authform_error.too_many_requests'))
|
||||
return
|
||||
}
|
||||
if (err instanceof UnauthorizedError) {
|
||||
error('無效的帳號名稱或密碼。')
|
||||
error(t('authform_error.unauthorized'))
|
||||
return
|
||||
}
|
||||
if (err instanceof ValidationError) {
|
||||
@@ -42,7 +45,7 @@ function handleRegister({
|
||||
return
|
||||
}
|
||||
console.error(err)
|
||||
error('出了點問題。')
|
||||
error(t('authform_error.auth_failed'))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,15 +62,15 @@ async function check({
|
||||
error: (msg: string) => void
|
||||
}) {
|
||||
if (username && !isValidUsername(username)) {
|
||||
error('無效的帳號名稱')
|
||||
error(t('authform_error.invalid_username'))
|
||||
return
|
||||
}
|
||||
if (email && !validator.isEmail(email)) {
|
||||
error('無效的電子郵件')
|
||||
error(t('authform_error.invalid_email'))
|
||||
return
|
||||
}
|
||||
if (password && !validator.isStrongPassword(password, passwordPolicy)) {
|
||||
error('密碼必須超過8個字元,至少包含一個數字、一個大寫及一個小寫。')
|
||||
error(t('authform_error.invalid_password'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -78,11 +81,11 @@ async function check({
|
||||
return
|
||||
}
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('太多請求,兩分鐘後再試一次。')
|
||||
error(t('authform_error.too_many_requests'))
|
||||
return
|
||||
}
|
||||
console.error(err)
|
||||
error('出了點問題。')
|
||||
error(t('authform_error.auth_failed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
+3
-26
@@ -1,30 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "../packages/utils" },
|
||||
{ "path": "../packages/constants" },
|
||||
{ "path": "../packages/file-graph" },
|
||||
{ "path": "../packages/models" }
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -5,7 +5,13 @@ import vue from '@vitejs/plugin-vue'
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/trpc': {
|
||||
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||
changeOrigin: false,
|
||||
xfwd: true,
|
||||
},
|
||||
'/api': {
|
||||
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||
changeOrigin: false,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[test]
|
||||
coverage = true
|
||||
coverageDir = "coverage"
|
||||
+1
-18
@@ -1,27 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
error_log /var/log/nginx/error.log debug;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /api-docs/ {
|
||||
proxy_pass http://backend:3000;
|
||||
}
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
+53
-32
@@ -1,7 +1,23 @@
|
||||
version: '3'
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.6.9
|
||||
ports:
|
||||
- 8000:8000
|
||||
- 8080:8080
|
||||
networks:
|
||||
- players
|
||||
- internal
|
||||
command:
|
||||
- '--api.insecure=true'
|
||||
- '--providers.docker=true'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--entrypoints.web.address=:8000'
|
||||
- '--entrypoints.web.forwardedHeaders.insecure=true' # trust all X-Forwarded headers
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
app:
|
||||
image: linuxodyssey/frontend
|
||||
image: linuxodyssey/frontend:${IMAGE_TAG:-latest}
|
||||
depends_on:
|
||||
- backend
|
||||
restart: always
|
||||
@@ -10,12 +26,16 @@ services:
|
||||
dockerfile: app/Dockerfile
|
||||
target: prod
|
||||
networks:
|
||||
internal:
|
||||
ports:
|
||||
- 8080:80
|
||||
- internal
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.app.entrypoints=web'
|
||||
- 'traefik.http.routers.app.rule=PathPrefix(`/`)'
|
||||
- 'traefik.http.routers.app.service=app'
|
||||
- 'traefik.http.services.app.loadbalancer.server.port=80'
|
||||
|
||||
backend:
|
||||
image: linuxodyssey/backend
|
||||
image: linuxodyssey/backend:${IMAGE_TAG:-latest}
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
@@ -29,21 +49,35 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./logs:/app/logs
|
||||
- ./config:/app/config
|
||||
- ./quests:/app/quests:ro
|
||||
env_file:
|
||||
- .env
|
||||
- .env.prod
|
||||
environment:
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
||||
- BACKEND_URL=http://backend:3000
|
||||
- BASE_URL=${BASE_URL}
|
||||
- HOST_PROJECT_ROOT=${PWD}
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.backend-api.entrypoints=web'
|
||||
- 'traefik.http.routers.backend-api.rule=PathPrefix(`/api`)'
|
||||
- 'traefik.http.routers.backend-api.service=backend'
|
||||
- 'traefik.http.routers.backend-socket.entrypoints=web'
|
||||
- 'traefik.http.routers.backend-socket.rule=PathPrefix(`/socket.io`)'
|
||||
- 'traefik.http.routers.backend-socket.service=backend'
|
||||
- 'traefik.http.routers.backend-trpc.entrypoints=web'
|
||||
- 'traefik.http.routers.backend-trpc.rule=PathPrefix(`/trpc`)'
|
||||
- 'traefik.http.routers.backend-trpc.service=backend'
|
||||
- 'traefik.http.routers.backend-api-docs.entrypoints=web'
|
||||
- 'traefik.http.routers.backend-api-docs.rule=PathPrefix(`/api-docs`)'
|
||||
- 'traefik.http.routers.backend-api-docs.service=backend'
|
||||
- 'traefik.http.services.backend.loadbalancer.server.port=3000'
|
||||
|
||||
analytics:
|
||||
image: linuxodyssey/analytics
|
||||
image: linuxodyssey/analytics:${IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/analytics/Dockerfile
|
||||
dockerfile: services/analytics/Dockerfile
|
||||
target: prod
|
||||
restart: always
|
||||
depends_on:
|
||||
@@ -52,13 +86,11 @@ services:
|
||||
- internal
|
||||
ports:
|
||||
- '3001:3001'
|
||||
environment:
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
env_file:
|
||||
- .env.prod
|
||||
|
||||
db:
|
||||
image: mongo
|
||||
image: mongo:6
|
||||
restart: always
|
||||
networks:
|
||||
- internal
|
||||
@@ -67,22 +99,11 @@ services:
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
||||
environment:
|
||||
- BASE_URL=${BASE_URL}
|
||||
- MONGO_INITDB_ROOT_USERNAME=admin
|
||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
|
||||
- MONGO_USER=${MONGO_USER}
|
||||
- MONGO_PASS=${MONGO_PASSWORD}
|
||||
- MONGO_DB=${MONGO_DB}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
||||
- SURVEY_URL=${SURVEY_URL}
|
||||
- BUG_REPORT_URL=${BUG_REPORT_URL}
|
||||
env_file:
|
||||
- .env.prod
|
||||
|
||||
base:
|
||||
image: linuxodyssey/quest-base
|
||||
image: linuxodyssey/quest-base:${IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./quests/Dockerfile
|
||||
|
||||
+56
-5
@@ -1,8 +1,27 @@
|
||||
version: '3'
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.6.9
|
||||
networks:
|
||||
- players-dev
|
||||
command:
|
||||
- '--api.insecure=true'
|
||||
- '--providers.docker=true'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--providers.file.directory=/etc/traefik/conf'
|
||||
- '--providers.file.watch=true'
|
||||
- '--entrypoints.web.address=:8000'
|
||||
ports:
|
||||
- '8000:8000'
|
||||
- '8080:8080'
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik/conf:/etc/traefik/conf
|
||||
|
||||
db:
|
||||
image: mongo
|
||||
restart: always
|
||||
image: mongo:6
|
||||
networks:
|
||||
- internal
|
||||
ports:
|
||||
@@ -10,14 +29,46 @@ services:
|
||||
volumes:
|
||||
- ./data:/data/db
|
||||
|
||||
postgres:
|
||||
image: postgres:18.6
|
||||
networks:
|
||||
- internal
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- ./postgres-data:/var/lib/postgresql
|
||||
environment:
|
||||
POSTGRES_USER: commander
|
||||
POSTGRES_DB: odyssey-dev
|
||||
POSTGRES_PASSWORD: mysecretpassword
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
networks:
|
||||
- internal
|
||||
ports:
|
||||
- 18080:8080
|
||||
|
||||
base:
|
||||
image: linuxodyssey/quest-base
|
||||
networks:
|
||||
- players-dev
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./quests/Dockerfile
|
||||
command: exit 0
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.base.rule=PathPrefix(`/terminal/base`)'
|
||||
- 'traefik.http.middlewares.base-stripprefix.stripprefix.prefixes=/terminal/base'
|
||||
- 'traefik.http.routers.base.middlewares=base-stripprefix'
|
||||
- 'traefik.http.routers.base.entrypoints=web'
|
||||
- 'traefik.http.services.base.loadbalancer.server.port=9090'
|
||||
|
||||
networks:
|
||||
internal:
|
||||
players:
|
||||
name: linux-odyssey-players
|
||||
internal: true
|
||||
players-dev:
|
||||
name: linux-odyssey-players-dev
|
||||
internal: false
|
||||
+4
-3
@@ -32,15 +32,16 @@ down() {
|
||||
dump() {
|
||||
# Define service name and dump directory name
|
||||
SERVICE_NAME="db"
|
||||
COMPOSE_FILE="docker-compose.prod.yml"
|
||||
DUMP_NAME="mongodump-$(date --iso).archive"
|
||||
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
|
||||
CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q $SERVICE_NAME)
|
||||
|
||||
# Fetch credentials from Docker environment variables
|
||||
MONGO_URL=$(docker compose exec -T backend printenv MONGO_URL)
|
||||
MONGO_URL=$(docker compose -f $COMPOSE_FILE exec -T backend printenv MONGO_URL)
|
||||
echo mongo uri: $MONGO_URL
|
||||
|
||||
# Dump MongoDB data using credentials
|
||||
docker compose exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME -d odyssey-test" &&
|
||||
docker compose -f $COMPOSE_FILE exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME --uri=$MONGO_URL" &&
|
||||
|
||||
# Copy the dump from the container to the host
|
||||
# Note: You will need to know the exact container name for this step
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Windows Environment Setup
|
||||
|
||||
## 1. Install WSL and Ubuntu
|
||||
|
||||
Refer to Microsoft’s official [WSL installation guide](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
|
||||
Open PowerShell as Administrator and run:
|
||||
|
||||
```powershell
|
||||
wsl --install
|
||||
```
|
||||
|
||||
Restart your computer after the installation finishes.
|
||||
|
||||
Open Ubuntu from the Start menu. The first time Ubuntu starts, it will ask you to create a Linux username and password.
|
||||
|
||||
Verify that Ubuntu is using WSL 2 by running this command in PowerShell:
|
||||
|
||||
```powershell
|
||||
wsl --update
|
||||
wsl --list --verbose
|
||||
```
|
||||
|
||||
Ubuntu should show `2` in the `VERSION` column.
|
||||
|
||||
If it is using WSL 1, run:
|
||||
|
||||
```powershell
|
||||
wsl --set-version Ubuntu 2
|
||||
```
|
||||
|
||||
## 2. Install Docker Desktop
|
||||
|
||||
Install [Docker Desktop for Windows](https://docs.docker.com/desktop/setup/install/windows-install/).
|
||||
|
||||
Start Docker Desktop after installation.
|
||||
|
||||
Docker Desktop normally uses the WSL 2 engine automatically. If the `Use the WSL 2 based engine` option appears under `Settings > General`, ensure it is enabled.
|
||||
|
||||
Go to:
|
||||
|
||||
```text
|
||||
Settings > Resources > WSL Integration
|
||||
```
|
||||
|
||||
Enable integration with your Ubuntu distribution, then select **Apply & restart**.
|
||||
|
||||
If the WSL Integration section is unavailable, ensure Docker Desktop is using Linux containers.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Do not separately install Docker Engine inside Ubuntu when using Docker Desktop. Having both installations may cause conflicts.
|
||||
|
||||
Open the Ubuntu terminal and verify that Docker is available:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
docker run --rm hello-world
|
||||
```
|
||||
|
||||
## 3. Set Up the Ubuntu Environment
|
||||
|
||||
Run the following commands in the Ubuntu terminal.
|
||||
|
||||
### Update the Package List
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
```
|
||||
|
||||
### Install the Required Tools
|
||||
|
||||
```bash
|
||||
sudo apt install -y git curl ca-certificates build-essential
|
||||
```
|
||||
|
||||
### Install Bun
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
```
|
||||
|
||||
Restart your terminal (or run `source ~/.bashrc`) so `bun` is on your `PATH`.
|
||||
|
||||
Verify the installations:
|
||||
|
||||
```bash
|
||||
git --version
|
||||
node --version
|
||||
bun --version
|
||||
```
|
||||
|
||||
The Node.js version should be `20.x` or later, and the Bun version should be `1.3` or later.
|
||||
|
||||
## 4. Clone the Project
|
||||
|
||||
> [!WARNING]
|
||||
> Clone the repository into the WSL Linux filesystem, such as `~/projects`. Do not place it on a Windows-mounted drive such as `/mnt/c/...` or `/mnt/d/...`. The project uses a bind mount for MongoDB data, and Windows-mounted directories may cause permission and filesystem compatibility problems.
|
||||
|
||||
Create and enter a project directory:
|
||||
|
||||
```bash
|
||||
mkdir -p <path-to-projects-directory>
|
||||
cd <path-to-projects-directory>
|
||||
```
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/linux-odyssey/linux-odyssey.git
|
||||
cd linux-odyssey
|
||||
```
|
||||
|
||||
### Optional: Open the Project in VS Code
|
||||
|
||||
Install [Visual Studio Code](https://code.visualstudio.com/) and the **WSL** extension first. Then run:
|
||||
|
||||
```bash
|
||||
code .
|
||||
```
|
||||
|
||||
## 5. Install Project Dependencies
|
||||
|
||||
From the project root, run:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
Warnings during installation may be harmless. The installation succeeded if Bun finishes with an `installed` message and does not display an `error:` message.
|
||||
|
||||
If a `gyp` error appears, confirm that it belongs to an optional dependency before ignoring it.
|
||||
|
||||
## 6. Create the Environment File
|
||||
|
||||
Copy the sample environment file:
|
||||
|
||||
```bash
|
||||
cp .env.sample .env
|
||||
```
|
||||
|
||||
Confirm that the file exists:
|
||||
|
||||
```bash
|
||||
ls -la .env
|
||||
```
|
||||
|
||||
The default values are suitable for local development. Do not use the sample secret key in a production environment.
|
||||
|
||||
## 7. Start the Docker Services
|
||||
|
||||
Ensure Docker Desktop is running, then execute:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Check that the containers are running:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
To stop the Docker services later:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 8. Run the Project
|
||||
|
||||
Start the frontend and backend development servers:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Keep the terminal running and open the following address in your browser:
|
||||
|
||||
[http://localhost:8000](http://localhost:8000)
|
||||
|
||||
To stop the development servers, press `Ctrl+C`.
|
||||
|
||||
### Optional: Verify the Production Build
|
||||
|
||||
You can verify that the project builds successfully by running:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
import type { Config } from 'jest'
|
||||
|
||||
const config: Config = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls, instances, contexts and results before every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: 'v8',
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// The default configuration for fake timers
|
||||
// fakeTimers: {
|
||||
// "enableGlobally": false
|
||||
// },
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "mjs",
|
||||
// "cjs",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "json",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1', // Redirect .js imports to .ts files
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state and implementation before every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
// testEnvironment: "jest-environment-node",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
}
|
||||
|
||||
export default config
|
||||
+26
-30
@@ -9,52 +9,48 @@
|
||||
"app",
|
||||
"server",
|
||||
"packages/*",
|
||||
"services/*",
|
||||
"tests"
|
||||
],
|
||||
"scripts": {
|
||||
"app": "yarn workspace app dev",
|
||||
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
|
||||
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
|
||||
"server": "yarn workspace server dev",
|
||||
"cli": "yarn workspace client start",
|
||||
"app": "bun run --filter app dev",
|
||||
"build": "bun run --filter app build",
|
||||
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"bun run --filter app dev\" \"bun run --filter server dev\"",
|
||||
"server": "bun run --filter server dev",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --ignore-path .prettierignore --write app server",
|
||||
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
|
||||
"test:e2e": "yarn workspace tests test",
|
||||
"test:open": "yarn workspace tests test:open",
|
||||
"analytics": "yarn workspace analytics dev",
|
||||
"setup": "yarn workspace server setup",
|
||||
"clean": "yarn workspace server clean",
|
||||
"build:quests": "yarn workspace server build:quests"
|
||||
"test": "bun test",
|
||||
"test:e2e": "bun run --filter tests test",
|
||||
"test:open": "bun run --filter tests test:open",
|
||||
"analytics": "bun run --filter analytics dev",
|
||||
"setup": "bun run --filter server setup",
|
||||
"clean": "bun run --filter server clean",
|
||||
"build:quests": "bun run --filter server build:quests",
|
||||
"type-check": "bun run --filter server --filter analytics --filter app type-check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.24.0",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/bun": "^1.4.2",
|
||||
"@types/node": "^20.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vue/eslint-config-prettier": "^7.1.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-cypress": "^2.15.1",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"jest": "^29.7.0",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"vue-eslint-parser": "^9.4.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"latest": "^0.2.0",
|
||||
"typescript-eslint": "^7.4.0"
|
||||
"packageManager": "bun@1.3.14",
|
||||
"overrides": {
|
||||
"@mapbox/node-pre-gyp/tar": "^7.5.19",
|
||||
"@cypress/request/form-data": "^2.5.4"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./package.json yarn.lock tsconfig.json ./
|
||||
COPY packages/analytics/package.json ./packages/analytics/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3001
|
||||
EXPOSE 3001
|
||||
|
||||
# Production stage
|
||||
FROM build as prod
|
||||
COPY packages/analytics ./packages/analytics
|
||||
COPY packages/models ./packages/models
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app/packages/analytics
|
||||
RUN yarn build
|
||||
CMD [ "yarn", "start" ]
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"watch": ["src", "views"],
|
||||
"ext": "ts,handlebars,js",
|
||||
"ignore": ["src/**/*.spec.ts"],
|
||||
"exec": "node --loader ts-node/esm src/index.ts"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "analytics",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "GPL",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc -b -f",
|
||||
"watch": "tsc -b --watch",
|
||||
"dev": "nodemon"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linux-odyssey/models": "*",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"express-handlebars": "^7.1.2",
|
||||
"mongoose": "^8.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.4.2",
|
||||
"@swc/helpers": "^0.5.6",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.20",
|
||||
"nodemon": "^3.1.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../models" }],
|
||||
"ts-node": {
|
||||
"swc": true
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,10 @@ export const getSessionsRequestSchema = z.object({
|
||||
})
|
||||
|
||||
export type GetSessionsRequest = z.infer<typeof getSessionsRequestSchema>
|
||||
|
||||
export const SUPPORTED_LOCALES = ['zh-TW', 'en'] as const
|
||||
export const LOCALE_NAME_MAP: Record<string, string> = {
|
||||
'zh-TW': '繁體中文',
|
||||
en: 'English',
|
||||
}
|
||||
export type QuestLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||
@@ -1,17 +1,13 @@
|
||||
{
|
||||
"name": "@linux-odyssey/constants",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"name": "@odyssey/constants",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"exports": "./dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "GPL",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc -w"
|
||||
"main": "index.ts",
|
||||
"types": "index.ts",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.24.2"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["*.js", "*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const toArray = (val: unknown) => (typeof val === 'string' ? [val] : val)
|
||||
@@ -1,98 +0,0 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
const fs = require('fs').promises
|
||||
const { exit } = require('process')
|
||||
const minimist = require('minimist')
|
||||
const axios = require('axios')
|
||||
const { colorize, printResponses, printHints } = require('./print.js')
|
||||
const discoverFiles = require('./discover.js')
|
||||
|
||||
const {
|
||||
API_ENDPOINT,
|
||||
TOKEN,
|
||||
PWD,
|
||||
CMD_NAME,
|
||||
CMD_OUTPUT_FILE,
|
||||
CMD_ERROR_FILE,
|
||||
CMD_EXIT_CODE,
|
||||
} = process.env
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_ENDPOINT}/api/v1`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
async function readOrNone(file) {
|
||||
try {
|
||||
return await fs.readFile(file, 'utf8')
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const commandListeners = {
|
||||
ls: [discoverFiles],
|
||||
}
|
||||
|
||||
async function handleCommand(command) {
|
||||
const argv = minimist(command.split(' '))
|
||||
const name = argv._[0]
|
||||
const listeners = commandListeners[name]
|
||||
if (!listeners) return {}
|
||||
return listeners.reduce(async (result, listener) => {
|
||||
const res = await listener(argv)
|
||||
return { ...res, ...result }
|
||||
}, {})
|
||||
}
|
||||
|
||||
async function handleResponse({ responses, hints, end }) {
|
||||
if (responses) {
|
||||
await printResponses(responses, 90)
|
||||
}
|
||||
if (hints) {
|
||||
await printHints(hints, 90)
|
||||
}
|
||||
if (end) {
|
||||
console.log(colorize('Quest completed!', 'blue'))
|
||||
}
|
||||
await api.post('/commands/completed')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!CMD_NAME) exit(0)
|
||||
|
||||
const params = await handleCommand(CMD_NAME)
|
||||
|
||||
const output = await readOrNone(CMD_OUTPUT_FILE)
|
||||
const error = await readOrNone(CMD_ERROR_FILE)
|
||||
const payload = {
|
||||
command: CMD_NAME,
|
||||
output,
|
||||
error,
|
||||
exit_code: CMD_EXIT_CODE,
|
||||
pwd: PWD,
|
||||
params,
|
||||
}
|
||||
try {
|
||||
const res = await api.post('/commands', payload)
|
||||
if (!res.data || !res.data.responses) return
|
||||
await handleResponse(res.data)
|
||||
} catch (err) {
|
||||
if (err.response) {
|
||||
console.error(err.response.data)
|
||||
return
|
||||
}
|
||||
console.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(exit)
|
||||
.catch((err) => {
|
||||
console.log(
|
||||
'發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。'
|
||||
)
|
||||
console.error(err)
|
||||
exit(1)
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
|
||||
async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
|
||||
const stats = await fs.stat(inputPath)
|
||||
const name = path.basename(inputPath)
|
||||
const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.'))
|
||||
const files = [
|
||||
{
|
||||
path: inputPath,
|
||||
name,
|
||||
type: stats.isDirectory() ? 'folder' : 'file',
|
||||
discovered,
|
||||
},
|
||||
]
|
||||
if (stats.isDirectory() && level < 2) {
|
||||
try {
|
||||
let entries = await fs.readdir(inputPath)
|
||||
if (level > 0) {
|
||||
entries = entries.slice(0, 5)
|
||||
}
|
||||
const result = await Promise.all(
|
||||
entries.map((entry) =>
|
||||
collectFilesInfo(path.join(inputPath, entry), level + 1, hiddenFiles)
|
||||
)
|
||||
)
|
||||
files.push(...result.flat())
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function discoverFiles(argv) {
|
||||
const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1)
|
||||
try {
|
||||
const result = await Promise.all(
|
||||
targetPath
|
||||
.map((p) => path.resolve(process.cwd(), p))
|
||||
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
|
||||
)
|
||||
return { discover: result.flat() }
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = discoverFiles
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "container",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "",
|
||||
"watch": ""
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"minimist": "^1.2.8"
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
/* eslint-disable no-await-in-loop */
|
||||
const readline = require('readline')
|
||||
|
||||
const RETURN_SYMBOL = '\u21B5'
|
||||
|
||||
// ANSI color map
|
||||
const ANSI_COLORS = {
|
||||
default: 0,
|
||||
black: 30,
|
||||
red: 31,
|
||||
green: 32,
|
||||
yellow: 33,
|
||||
blue: 34,
|
||||
magenta: 35,
|
||||
cyan: 36,
|
||||
white: 37,
|
||||
}
|
||||
|
||||
function colorCode(color) {
|
||||
return `\x1b[${ANSI_COLORS[color]}m`
|
||||
}
|
||||
|
||||
function colorize(content, color) {
|
||||
if (!color) return content
|
||||
return `${colorCode(color)}${content}${colorCode('default')}`
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(true)
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false,
|
||||
})
|
||||
|
||||
function waitForEnter() {
|
||||
return new Promise((resolve) => {
|
||||
rl.once('line', resolve)
|
||||
})
|
||||
}
|
||||
|
||||
function formatLine(content, color) {
|
||||
let result = colorCode(color)
|
||||
let codeBlock = false
|
||||
for (const c of content) {
|
||||
switch (c) {
|
||||
case '`':
|
||||
result += colorCode(codeBlock ? color : 'green')
|
||||
codeBlock = !codeBlock
|
||||
break
|
||||
|
||||
default:
|
||||
result += c
|
||||
}
|
||||
}
|
||||
return result + colorCode('default') + RETURN_SYMBOL
|
||||
}
|
||||
|
||||
function printLine(content, delay, color) {
|
||||
const printContent = formatLine(content, color)
|
||||
return new Promise((resolve) => {
|
||||
let i = 0
|
||||
const timer = setInterval(() => {
|
||||
process.stdout.write(printContent[i])
|
||||
i += 1
|
||||
if (i >= printContent.length) {
|
||||
clearInterval(timer)
|
||||
process.stdout.write('\n')
|
||||
resolve()
|
||||
}
|
||||
}, delay)
|
||||
|
||||
waitForEnter().then(() => {
|
||||
clearInterval(timer)
|
||||
if (i >= printContent.length) return
|
||||
process.stdout.write(printContent.slice(i))
|
||||
process.stdout.write('\n')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function printResponses(responses, delay = 100) {
|
||||
console.log()
|
||||
console.log(colorize('==============================', 'green'))
|
||||
for (const response of responses) {
|
||||
const { type, content, speaker, color } = response
|
||||
if (type === 'narrative') {
|
||||
for (const line of content) {
|
||||
await printLine(line, delay, color || 'cyan')
|
||||
}
|
||||
}
|
||||
if (type === 'dialogue') {
|
||||
console.log(colorize(`${speaker}:`, 'green'))
|
||||
for (const line of content) {
|
||||
await printLine(` ${line}`, delay, color || 'cyan')
|
||||
}
|
||||
}
|
||||
console.log(colorize('------------------------------', 'green'))
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
async function printHints(hints, delay = 100) {
|
||||
for (const hint of hints) {
|
||||
await printLine(hint, delay, 'yellow')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
printResponses,
|
||||
printHints,
|
||||
colorize,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copy this file to `.env` and replace the placeholder with your real connection string.
|
||||
# Connection string for PostgreSQL.
|
||||
# Requires PostgreSQL >= 15.
|
||||
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
|
||||
@@ -0,0 +1,6 @@
|
||||
src/prisma/contract.json linguist-generated
|
||||
src/prisma/contract.d.ts linguist-generated
|
||||
src/prisma/ops.json linguist-generated
|
||||
src/prisma/migration.json linguist-generated
|
||||
migrations/snapshots/**/contract.json linguist-generated
|
||||
migrations/snapshots/**/contract.d.ts linguist-generated
|
||||
@@ -0,0 +1,36 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,15 @@
|
||||
# @odyssey/database
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.3.14. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"from": null,
|
||||
"to": "6fecb00f7a40489f8c1a9a4dad506cc6d4b95789cbb32519634132f006530c04",
|
||||
"providedInvariants": [],
|
||||
"createdAt": "2026-10-11T08:21:15.726Z",
|
||||
"migrationHash": "99b3a1febb9f739bf258c99ebe1dbfe692b86792e8eb511fc7eef8e985e8e8cd"
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env -S node
|
||||
import type { Contract as End } from '../../snapshots/6fecb00f7a40489f8c1a9a4dad506cc6d4b95789cbb32519634132f006530c04/contract';
|
||||
import endContract from '../../snapshots/6fecb00f7a40489f8c1a9a4dad506cc6d4b95789cbb32519634132f006530c04/contract.json' with { type: 'json' };
|
||||
import {
|
||||
Migration,
|
||||
MigrationCLI,
|
||||
checkExpression,
|
||||
col,
|
||||
primaryKey,
|
||||
} from '@prisma/orm-postgres/migration';
|
||||
|
||||
export default class M extends Migration<never, End> {
|
||||
override readonly endContractJson = endContract;
|
||||
|
||||
override get operations() {
|
||||
return [
|
||||
this.createSchema({ schema: 'public' }),
|
||||
this.createTable({
|
||||
schema: 'public',
|
||||
table: 'Command',
|
||||
columns: [
|
||||
col('command', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('error', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('id', 'BIGSERIAL', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
|
||||
col('output', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('pwd', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('sessionId', 'int8', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
|
||||
col('stage', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('timestamp', 'timestamptz', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
|
||||
}),
|
||||
],
|
||||
constraints: [primaryKey(['id'])],
|
||||
}),
|
||||
this.createTable({
|
||||
schema: 'public',
|
||||
table: 'LoginAttempt',
|
||||
columns: [
|
||||
col('id', 'BIGSERIAL', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
|
||||
col('ip', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('loginMethod', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('message', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('success', 'bool', { notNull: true, codecRef: { codecId: 'pg/bool@1' } }),
|
||||
col('time', 'timestamptz', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
|
||||
}),
|
||||
col('userAgent', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('userId', 'int8', { codecRef: { codecId: 'pg/int8@1' } }),
|
||||
col('username', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
],
|
||||
constraints: [
|
||||
primaryKey(['id']),
|
||||
checkExpression(
|
||||
'LoginAttempt_loginMethod_check_f8485f11',
|
||||
`"loginMethod" IN ('password', 'google', 'github')`,
|
||||
),
|
||||
],
|
||||
}),
|
||||
this.createTable({
|
||||
schema: 'public',
|
||||
table: 'Session',
|
||||
columns: [
|
||||
col('completedEvents', 'text[]', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/text@1', many: true },
|
||||
}),
|
||||
col('containerId', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('containerName', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('createdAt', 'timestamptz', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
|
||||
}),
|
||||
col('finishedAt', 'timestamptz', { codecRef: { codecId: 'pg/timestamptz-temporal@1' } }),
|
||||
col('graph', 'jsonb', { notNull: true, codecRef: { codecId: 'pg/jsonb@1' } }),
|
||||
col('id', 'BIGSERIAL', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
|
||||
col('lastActivityAt', 'timestamptz', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
|
||||
}),
|
||||
col('locale', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('quest', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('status', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('userId', 'int8', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
|
||||
],
|
||||
constraints: [
|
||||
primaryKey(['id']),
|
||||
checkExpression(
|
||||
'Session_completedEvents_elem_not_null_b55d23f4',
|
||||
'array_position("completedEvents", NULL) IS NULL',
|
||||
),
|
||||
checkExpression(
|
||||
'Session_status_check_8fa5bbc6',
|
||||
`"status" IN ('active', 'finished', 'inactive')`,
|
||||
),
|
||||
],
|
||||
}),
|
||||
this.createTable({
|
||||
schema: 'public',
|
||||
table: 'User',
|
||||
columns: [
|
||||
col('createdAt', 'timestamptz', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
|
||||
}),
|
||||
col('email', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('id', 'BIGSERIAL', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
|
||||
col('isGuest', 'bool', { notNull: true, codecRef: { codecId: 'pg/bool@1' } }),
|
||||
col('name', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('updatedAt', 'timestamptz', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
|
||||
}),
|
||||
col('username', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
],
|
||||
constraints: [primaryKey(['id'])],
|
||||
}),
|
||||
this.createTable({
|
||||
schema: 'public',
|
||||
table: 'UserLoginMethod',
|
||||
columns: [
|
||||
col('createdAt', 'timestamptz', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
|
||||
}),
|
||||
col('displayName', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('hashedPassword', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('oauthId', 'text', { codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('type', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('updatedAt', 'timestamptz', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
|
||||
}),
|
||||
col('userId', 'int8', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
|
||||
],
|
||||
constraints: [
|
||||
primaryKey(['userId', 'type']),
|
||||
checkExpression(
|
||||
'UserLoginMethod_type_check_6814314f',
|
||||
`"type" IN ('password', 'google', 'github')`,
|
||||
),
|
||||
],
|
||||
}),
|
||||
this.createTable({
|
||||
schema: 'public',
|
||||
table: 'UserProgress',
|
||||
columns: [
|
||||
col('finishedAt', 'timestamptz', { codecRef: { codecId: 'pg/timestamptz-temporal@1' } }),
|
||||
col('quest', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
|
||||
col('startedAt', 'timestamptz', {
|
||||
notNull: true,
|
||||
codecRef: { codecId: 'pg/timestamptz-temporal@1' },
|
||||
}),
|
||||
col('userId', 'int8', { notNull: true, codecRef: { codecId: 'pg/int8@1' } }),
|
||||
],
|
||||
constraints: [primaryKey(['userId', 'quest'])],
|
||||
}),
|
||||
this.addUnique({
|
||||
schema: 'public',
|
||||
table: 'User',
|
||||
constraint: 'User_email_key',
|
||||
columns: ['email'],
|
||||
}),
|
||||
this.addUnique({
|
||||
schema: 'public',
|
||||
table: 'User',
|
||||
constraint: 'User_username_key',
|
||||
columns: ['username'],
|
||||
}),
|
||||
this.addUnique({
|
||||
schema: 'public',
|
||||
table: 'UserLoginMethod',
|
||||
constraint: 'UserLoginMethod_type_oauthId_key',
|
||||
columns: ['type', 'oauthId'],
|
||||
}),
|
||||
this.createIndex({
|
||||
schema: 'public',
|
||||
table: 'Command',
|
||||
index: 'Command_sessionId_idx_29f415d4',
|
||||
columns: ['sessionId'],
|
||||
}),
|
||||
this.createIndex({
|
||||
schema: 'public',
|
||||
table: 'LoginAttempt',
|
||||
index: 'LoginAttempt_userId_idx_a489d58a',
|
||||
columns: ['userId'],
|
||||
}),
|
||||
this.createIndex({
|
||||
schema: 'public',
|
||||
table: 'Session',
|
||||
index: 'Session_userId_idx_a489d58a',
|
||||
columns: ['userId'],
|
||||
}),
|
||||
this.createIndex({
|
||||
schema: 'public',
|
||||
table: 'Session',
|
||||
index: 'Session_userId_quest_idx_b13626bf',
|
||||
columns: ['userId', 'quest'],
|
||||
}),
|
||||
this.createIndex({
|
||||
schema: 'public',
|
||||
table: 'Session',
|
||||
index: 'Session_userId_status_idx_e4a128ba',
|
||||
columns: ['userId', 'status'],
|
||||
}),
|
||||
this.createIndex({
|
||||
schema: 'public',
|
||||
table: 'UserLoginMethod',
|
||||
index: 'UserLoginMethod_userId_idx_a489d58a',
|
||||
columns: ['userId'],
|
||||
}),
|
||||
this.createIndex({
|
||||
schema: 'public',
|
||||
table: 'UserProgress',
|
||||
index: 'UserProgress_userId_idx_a489d58a',
|
||||
columns: ['userId'],
|
||||
}),
|
||||
this.addForeignKey({
|
||||
schema: 'public',
|
||||
table: 'Command',
|
||||
foreignKey: {
|
||||
name: 'Command_sessionId_fkey',
|
||||
columns: ['sessionId'],
|
||||
references: { schema: 'public', table: 'Session', columns: ['id'] },
|
||||
},
|
||||
}),
|
||||
this.addForeignKey({
|
||||
schema: 'public',
|
||||
table: 'LoginAttempt',
|
||||
foreignKey: {
|
||||
name: 'LoginAttempt_userId_fkey',
|
||||
columns: ['userId'],
|
||||
references: { schema: 'public', table: 'User', columns: ['id'] },
|
||||
},
|
||||
}),
|
||||
this.addForeignKey({
|
||||
schema: 'public',
|
||||
table: 'Session',
|
||||
foreignKey: {
|
||||
name: 'Session_userId_fkey',
|
||||
columns: ['userId'],
|
||||
references: { schema: 'public', table: 'User', columns: ['id'] },
|
||||
},
|
||||
}),
|
||||
this.addForeignKey({
|
||||
schema: 'public',
|
||||
table: 'UserLoginMethod',
|
||||
foreignKey: {
|
||||
name: 'UserLoginMethod_userId_fkey',
|
||||
columns: ['userId'],
|
||||
references: { schema: 'public', table: 'User', columns: ['id'] },
|
||||
},
|
||||
}),
|
||||
this.addForeignKey({
|
||||
schema: 'public',
|
||||
table: 'UserProgress',
|
||||
foreignKey: {
|
||||
name: 'UserProgress_userId_fkey',
|
||||
columns: ['userId'],
|
||||
references: { schema: 'public', table: 'User', columns: ['id'] },
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
MigrationCLI.run(import.meta.url, M);
|
||||
@@ -0,0 +1,862 @@
|
||||
[
|
||||
{
|
||||
"id": "schema.public",
|
||||
"label": "Create schema \"public\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres"
|
||||
},
|
||||
"precheck": [],
|
||||
"execute": [
|
||||
{
|
||||
"description": "Create schema \"public\"",
|
||||
"sql": "CREATE SCHEMA IF NOT EXISTS \"public\"",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": []
|
||||
},
|
||||
{
|
||||
"id": "table.Command",
|
||||
"label": "Create table \"Command\"",
|
||||
"summary": "Creates table \"Command\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "table",
|
||||
"name": "Command"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure table \"Command\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Command\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create table \"Command\"",
|
||||
"sql": "CREATE TABLE \"public\".\"Command\" (\n \"command\" text NOT NULL,\n \"error\" text,\n \"id\" BIGSERIAL NOT NULL,\n \"output\" text,\n \"pwd\" text NOT NULL,\n \"sessionId\" int8 NOT NULL,\n \"stage\" text,\n \"timestamp\" timestamptz NOT NULL,\n PRIMARY KEY (\"id\")\n)",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify table \"Command\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Command\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "table.LoginAttempt",
|
||||
"label": "Create table \"LoginAttempt\"",
|
||||
"summary": "Creates table \"LoginAttempt\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "table",
|
||||
"name": "LoginAttempt"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure table \"LoginAttempt\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"LoginAttempt\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create table \"LoginAttempt\"",
|
||||
"sql": "CREATE TABLE \"public\".\"LoginAttempt\" (\n \"id\" BIGSERIAL NOT NULL,\n \"ip\" text NOT NULL,\n \"loginMethod\" text NOT NULL,\n \"message\" text,\n \"success\" bool NOT NULL,\n \"time\" timestamptz NOT NULL,\n \"userAgent\" text NOT NULL,\n \"userId\" int8,\n \"username\" text NOT NULL,\n PRIMARY KEY (\"id\"),\n CONSTRAINT \"LoginAttempt_loginMethod_check_f8485f11\" CHECK (\"loginMethod\" IN ('password', 'google', 'github'))\n)",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify table \"LoginAttempt\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"LoginAttempt\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "table.Session",
|
||||
"label": "Create table \"Session\"",
|
||||
"summary": "Creates table \"Session\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "table",
|
||||
"name": "Session"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure table \"Session\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Session\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create table \"Session\"",
|
||||
"sql": "CREATE TABLE \"public\".\"Session\" (\n \"completedEvents\" text[] NOT NULL,\n \"containerId\" text,\n \"containerName\" text,\n \"createdAt\" timestamptz NOT NULL,\n \"finishedAt\" timestamptz,\n \"graph\" jsonb NOT NULL,\n \"id\" BIGSERIAL NOT NULL,\n \"lastActivityAt\" timestamptz NOT NULL,\n \"locale\" text NOT NULL,\n \"quest\" text NOT NULL,\n \"status\" text NOT NULL,\n \"userId\" int8 NOT NULL,\n PRIMARY KEY (\"id\"),\n CONSTRAINT \"Session_completedEvents_elem_not_null_b55d23f4\" CHECK (array_position(\"completedEvents\", NULL) IS NULL),\n CONSTRAINT \"Session_status_check_8fa5bbc6\" CHECK (\"status\" IN ('active', 'finished', 'inactive'))\n)",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify table \"Session\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Session\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "table.User",
|
||||
"label": "Create table \"User\"",
|
||||
"summary": "Creates table \"User\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "table",
|
||||
"name": "User"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure table \"User\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"User\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create table \"User\"",
|
||||
"sql": "CREATE TABLE \"public\".\"User\" (\n \"createdAt\" timestamptz NOT NULL,\n \"email\" text NOT NULL,\n \"id\" BIGSERIAL NOT NULL,\n \"isGuest\" bool NOT NULL,\n \"name\" text,\n \"updatedAt\" timestamptz NOT NULL,\n \"username\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify table \"User\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"User\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "table.UserLoginMethod",
|
||||
"label": "Create table \"UserLoginMethod\"",
|
||||
"summary": "Creates table \"UserLoginMethod\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "table",
|
||||
"name": "UserLoginMethod"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure table \"UserLoginMethod\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"UserLoginMethod\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create table \"UserLoginMethod\"",
|
||||
"sql": "CREATE TABLE \"public\".\"UserLoginMethod\" (\n \"createdAt\" timestamptz NOT NULL,\n \"displayName\" text,\n \"hashedPassword\" text,\n \"oauthId\" text,\n \"type\" text NOT NULL,\n \"updatedAt\" timestamptz NOT NULL,\n \"userId\" int8 NOT NULL,\n PRIMARY KEY (\"userId\", \"type\"),\n CONSTRAINT \"UserLoginMethod_type_check_6814314f\" CHECK (\"type\" IN ('password', 'google', 'github'))\n)",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify table \"UserLoginMethod\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"UserLoginMethod\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "table.UserProgress",
|
||||
"label": "Create table \"UserProgress\"",
|
||||
"summary": "Creates table \"UserProgress\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "table",
|
||||
"name": "UserProgress"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure table \"UserProgress\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"UserProgress\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create table \"UserProgress\"",
|
||||
"sql": "CREATE TABLE \"public\".\"UserProgress\" (\n \"finishedAt\" timestamptz,\n \"quest\" text NOT NULL,\n \"startedAt\" timestamptz NOT NULL,\n \"userId\" int8 NOT NULL,\n PRIMARY KEY (\"userId\", \"quest\")\n)",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify table \"UserProgress\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"UserProgress\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unique.User.User_email_key",
|
||||
"label": "Add unique constraint on \"User\" (email)",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "unique",
|
||||
"name": "User_email_key",
|
||||
"table": "User"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure constraint \"User_email_key\" does not exist",
|
||||
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"User_email_key",
|
||||
"public",
|
||||
"\"public\".\"User\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "add unique constraint \"User_email_key\"",
|
||||
"sql": "ALTER TABLE \"public\".\"User\" ADD CONSTRAINT \"User_email_key\" UNIQUE (\"email\")"
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify constraint \"User_email_key\" exists",
|
||||
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"User_email_key",
|
||||
"public",
|
||||
"\"public\".\"User\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unique.User.User_username_key",
|
||||
"label": "Add unique constraint on \"User\" (username)",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "unique",
|
||||
"name": "User_username_key",
|
||||
"table": "User"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure constraint \"User_username_key\" does not exist",
|
||||
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"User_username_key",
|
||||
"public",
|
||||
"\"public\".\"User\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "add unique constraint \"User_username_key\"",
|
||||
"sql": "ALTER TABLE \"public\".\"User\" ADD CONSTRAINT \"User_username_key\" UNIQUE (\"username\")"
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify constraint \"User_username_key\" exists",
|
||||
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"User_username_key",
|
||||
"public",
|
||||
"\"public\".\"User\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unique.UserLoginMethod.UserLoginMethod_type_oauthId_key",
|
||||
"label": "Add unique constraint on \"UserLoginMethod\" (type, oauthId)",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "unique",
|
||||
"name": "UserLoginMethod_type_oauthId_key",
|
||||
"table": "UserLoginMethod"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure constraint \"UserLoginMethod_type_oauthId_key\" does not exist",
|
||||
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"UserLoginMethod_type_oauthId_key",
|
||||
"public",
|
||||
"\"public\".\"UserLoginMethod\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "add unique constraint \"UserLoginMethod_type_oauthId_key\"",
|
||||
"sql": "ALTER TABLE \"public\".\"UserLoginMethod\" ADD CONSTRAINT \"UserLoginMethod_type_oauthId_key\" UNIQUE (\"type\", \"oauthId\")"
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify constraint \"UserLoginMethod_type_oauthId_key\" exists",
|
||||
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"UserLoginMethod_type_oauthId_key",
|
||||
"public",
|
||||
"\"public\".\"UserLoginMethod\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "index.Command.Command_sessionId_idx_29f415d4",
|
||||
"label": "Create index \"Command_sessionId_idx_29f415d4\" on \"Command\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "index",
|
||||
"name": "Command_sessionId_idx_29f415d4",
|
||||
"table": "Command"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure index \"Command_sessionId_idx_29f415d4\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Command_sessionId_idx_29f415d4\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create index \"Command_sessionId_idx_29f415d4\"",
|
||||
"sql": "CREATE INDEX \"Command_sessionId_idx_29f415d4\" ON \"public\".\"Command\" (\"sessionId\")",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify index \"Command_sessionId_idx_29f415d4\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Command_sessionId_idx_29f415d4\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "index.LoginAttempt.LoginAttempt_userId_idx_a489d58a",
|
||||
"label": "Create index \"LoginAttempt_userId_idx_a489d58a\" on \"LoginAttempt\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "index",
|
||||
"name": "LoginAttempt_userId_idx_a489d58a",
|
||||
"table": "LoginAttempt"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure index \"LoginAttempt_userId_idx_a489d58a\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"LoginAttempt_userId_idx_a489d58a\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create index \"LoginAttempt_userId_idx_a489d58a\"",
|
||||
"sql": "CREATE INDEX \"LoginAttempt_userId_idx_a489d58a\" ON \"public\".\"LoginAttempt\" (\"userId\")",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify index \"LoginAttempt_userId_idx_a489d58a\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"LoginAttempt_userId_idx_a489d58a\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "index.Session.Session_userId_idx_a489d58a",
|
||||
"label": "Create index \"Session_userId_idx_a489d58a\" on \"Session\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "index",
|
||||
"name": "Session_userId_idx_a489d58a",
|
||||
"table": "Session"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure index \"Session_userId_idx_a489d58a\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Session_userId_idx_a489d58a\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create index \"Session_userId_idx_a489d58a\"",
|
||||
"sql": "CREATE INDEX \"Session_userId_idx_a489d58a\" ON \"public\".\"Session\" (\"userId\")",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify index \"Session_userId_idx_a489d58a\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Session_userId_idx_a489d58a\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "index.Session.Session_userId_quest_idx_b13626bf",
|
||||
"label": "Create index \"Session_userId_quest_idx_b13626bf\" on \"Session\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "index",
|
||||
"name": "Session_userId_quest_idx_b13626bf",
|
||||
"table": "Session"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure index \"Session_userId_quest_idx_b13626bf\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Session_userId_quest_idx_b13626bf\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create index \"Session_userId_quest_idx_b13626bf\"",
|
||||
"sql": "CREATE INDEX \"Session_userId_quest_idx_b13626bf\" ON \"public\".\"Session\" (\"userId\", \"quest\")",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify index \"Session_userId_quest_idx_b13626bf\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Session_userId_quest_idx_b13626bf\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "index.Session.Session_userId_status_idx_e4a128ba",
|
||||
"label": "Create index \"Session_userId_status_idx_e4a128ba\" on \"Session\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "index",
|
||||
"name": "Session_userId_status_idx_e4a128ba",
|
||||
"table": "Session"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure index \"Session_userId_status_idx_e4a128ba\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Session_userId_status_idx_e4a128ba\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create index \"Session_userId_status_idx_e4a128ba\"",
|
||||
"sql": "CREATE INDEX \"Session_userId_status_idx_e4a128ba\" ON \"public\".\"Session\" (\"userId\", \"status\")",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify index \"Session_userId_status_idx_e4a128ba\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"Session_userId_status_idx_e4a128ba\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "index.UserLoginMethod.UserLoginMethod_userId_idx_a489d58a",
|
||||
"label": "Create index \"UserLoginMethod_userId_idx_a489d58a\" on \"UserLoginMethod\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "index",
|
||||
"name": "UserLoginMethod_userId_idx_a489d58a",
|
||||
"table": "UserLoginMethod"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure index \"UserLoginMethod_userId_idx_a489d58a\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"UserLoginMethod_userId_idx_a489d58a\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create index \"UserLoginMethod_userId_idx_a489d58a\"",
|
||||
"sql": "CREATE INDEX \"UserLoginMethod_userId_idx_a489d58a\" ON \"public\".\"UserLoginMethod\" (\"userId\")",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify index \"UserLoginMethod_userId_idx_a489d58a\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"UserLoginMethod_userId_idx_a489d58a\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "index.UserProgress.UserProgress_userId_idx_a489d58a",
|
||||
"label": "Create index \"UserProgress_userId_idx_a489d58a\" on \"UserProgress\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "index",
|
||||
"name": "UserProgress_userId_idx_a489d58a",
|
||||
"table": "UserProgress"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure index \"UserProgress_userId_idx_a489d58a\" does not exist",
|
||||
"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"UserProgress_userId_idx_a489d58a\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "create index \"UserProgress_userId_idx_a489d58a\"",
|
||||
"sql": "CREATE INDEX \"UserProgress_userId_idx_a489d58a\" ON \"public\".\"UserProgress\" (\"userId\")",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify index \"UserProgress_userId_idx_a489d58a\" exists",
|
||||
"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"",
|
||||
"params": [
|
||||
"\"public\".\"UserProgress_userId_idx_a489d58a\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "foreignKey.Command.Command_sessionId_fkey",
|
||||
"label": "Add foreign key \"Command_sessionId_fkey\" on \"Command\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "foreignKey",
|
||||
"name": "Command_sessionId_fkey",
|
||||
"table": "Command"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure FK \"Command_sessionId_fkey\" does not exist",
|
||||
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"Command_sessionId_fkey",
|
||||
"public",
|
||||
"\"public\".\"Command\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "add FK \"Command_sessionId_fkey\"",
|
||||
"sql": "ALTER TABLE \"public\".\"Command\"\nADD CONSTRAINT \"Command_sessionId_fkey\"\nFOREIGN KEY (\"sessionId\")\nREFERENCES \"public\".\"Session\" (\"id\")"
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify FK \"Command_sessionId_fkey\" exists",
|
||||
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"Command_sessionId_fkey",
|
||||
"public",
|
||||
"\"public\".\"Command\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "foreignKey.LoginAttempt.LoginAttempt_userId_fkey",
|
||||
"label": "Add foreign key \"LoginAttempt_userId_fkey\" on \"LoginAttempt\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "foreignKey",
|
||||
"name": "LoginAttempt_userId_fkey",
|
||||
"table": "LoginAttempt"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure FK \"LoginAttempt_userId_fkey\" does not exist",
|
||||
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"LoginAttempt_userId_fkey",
|
||||
"public",
|
||||
"\"public\".\"LoginAttempt\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "add FK \"LoginAttempt_userId_fkey\"",
|
||||
"sql": "ALTER TABLE \"public\".\"LoginAttempt\"\nADD CONSTRAINT \"LoginAttempt_userId_fkey\"\nFOREIGN KEY (\"userId\")\nREFERENCES \"public\".\"User\" (\"id\")"
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify FK \"LoginAttempt_userId_fkey\" exists",
|
||||
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"LoginAttempt_userId_fkey",
|
||||
"public",
|
||||
"\"public\".\"LoginAttempt\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "foreignKey.Session.Session_userId_fkey",
|
||||
"label": "Add foreign key \"Session_userId_fkey\" on \"Session\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "foreignKey",
|
||||
"name": "Session_userId_fkey",
|
||||
"table": "Session"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure FK \"Session_userId_fkey\" does not exist",
|
||||
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"Session_userId_fkey",
|
||||
"public",
|
||||
"\"public\".\"Session\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "add FK \"Session_userId_fkey\"",
|
||||
"sql": "ALTER TABLE \"public\".\"Session\"\nADD CONSTRAINT \"Session_userId_fkey\"\nFOREIGN KEY (\"userId\")\nREFERENCES \"public\".\"User\" (\"id\")"
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify FK \"Session_userId_fkey\" exists",
|
||||
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"Session_userId_fkey",
|
||||
"public",
|
||||
"\"public\".\"Session\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "foreignKey.UserLoginMethod.UserLoginMethod_userId_fkey",
|
||||
"label": "Add foreign key \"UserLoginMethod_userId_fkey\" on \"UserLoginMethod\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "foreignKey",
|
||||
"name": "UserLoginMethod_userId_fkey",
|
||||
"table": "UserLoginMethod"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure FK \"UserLoginMethod_userId_fkey\" does not exist",
|
||||
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"UserLoginMethod_userId_fkey",
|
||||
"public",
|
||||
"\"public\".\"UserLoginMethod\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "add FK \"UserLoginMethod_userId_fkey\"",
|
||||
"sql": "ALTER TABLE \"public\".\"UserLoginMethod\"\nADD CONSTRAINT \"UserLoginMethod_userId_fkey\"\nFOREIGN KEY (\"userId\")\nREFERENCES \"public\".\"User\" (\"id\")"
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify FK \"UserLoginMethod_userId_fkey\" exists",
|
||||
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"UserLoginMethod_userId_fkey",
|
||||
"public",
|
||||
"\"public\".\"UserLoginMethod\""
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "foreignKey.UserProgress.UserProgress_userId_fkey",
|
||||
"label": "Add foreign key \"UserProgress_userId_fkey\" on \"UserProgress\"",
|
||||
"operationClass": "additive",
|
||||
"target": {
|
||||
"id": "postgres",
|
||||
"details": {
|
||||
"schema": "public",
|
||||
"objectType": "foreignKey",
|
||||
"name": "UserProgress_userId_fkey",
|
||||
"table": "UserProgress"
|
||||
}
|
||||
},
|
||||
"precheck": [
|
||||
{
|
||||
"description": "ensure FK \"UserProgress_userId_fkey\" does not exist",
|
||||
"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"UserProgress_userId_fkey",
|
||||
"public",
|
||||
"\"public\".\"UserProgress\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"execute": [
|
||||
{
|
||||
"description": "add FK \"UserProgress_userId_fkey\"",
|
||||
"sql": "ALTER TABLE \"public\".\"UserProgress\"\nADD CONSTRAINT \"UserProgress_userId_fkey\"\nFOREIGN KEY (\"userId\")\nREFERENCES \"public\".\"User\" (\"id\")"
|
||||
}
|
||||
],
|
||||
"postcheck": [
|
||||
{
|
||||
"description": "verify FK \"UserProgress_userId_fkey\" exists",
|
||||
"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"",
|
||||
"params": [
|
||||
"UserProgress_userId_fkey",
|
||||
"public",
|
||||
"\"public\".\"UserProgress\""
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Generated
Vendored
+1609
File diff suppressed because it is too large.
Load diff
+1
@@ -0,0 +1 @@
|
||||
{"_generated":{"message":"This file is automatically generated by \"prisma contract emit\".","regenerate":"To regenerate, run: prisma contract emit","warning":"⚠️ GENERATED FILE - DO NOT EDIT"},"capabilities":{"postgres":{"distinctOn":true,"forKeyShare":true,"forNoKeyUpdate":true,"jsonAgg":true,"lateral":true,"limit":true,"orderBy":true,"returning":true},"sql":{"checkConstraint":true,"defaultInInsert":true,"enums":true,"forShare":true,"forUpdate":true,"insertOnConflictSkip":true,"insertOnConflictWithoutTarget":true,"lateral":true,"lockNowait":true,"lockOf":true,"lockSkipLocked":true,"returning":true,"scalarList":true}},"domain":{"namespaces":{"public":{"enum":{"LoginMethod":{"codecId":"pg/text@1","members":[{"name":"Password","value":"password"},{"name":"Google","value":"google"},{"name":"GitHub","value":"github"}]},"SessionStatus":{"codecId":"pg/text@1","members":[{"name":"Active","value":"active"},{"name":"Finished","value":"finished"},{"name":"Inactive","value":"inactive"}]}},"models":{"Command":{"fields":{"command":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"error":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"output":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"pwd":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"sessionId":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"stage":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"timestamp":{"nullable":false,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}}},"relations":{"session":{"cardinality":"N:1","nullable":false,"on":{"localFields":["sessionId"],"targetFields":["id"]},"to":{"model":"Session","namespace":"public"}}},"storage":{"fields":{"command":{"column":"command"},"error":{"column":"error"},"id":{"column":"id"},"output":{"column":"output"},"pwd":{"column":"pwd"},"sessionId":{"column":"sessionId"},"stage":{"column":"stage"},"timestamp":{"column":"timestamp"}},"namespaceId":"public","table":"Command"}},"LoginAttempt":{"fields":{"id":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"ip":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"loginMethod":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"},"valueSet":{"entityKind":"enum","entityName":"LoginMethod","namespaceId":"public","plane":"domain"}},"message":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"success":{"nullable":false,"type":{"codecId":"pg/bool@1","kind":"scalar"}},"time":{"nullable":false,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}},"userAgent":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"userId":{"nullable":true,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"username":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}}},"relations":{"user":{"cardinality":"N:1","nullable":true,"on":{"localFields":["userId"],"targetFields":["id"]},"to":{"model":"User","namespace":"public"}}},"storage":{"fields":{"id":{"column":"id"},"ip":{"column":"ip"},"loginMethod":{"column":"loginMethod"},"message":{"column":"message"},"success":{"column":"success"},"time":{"column":"time"},"userAgent":{"column":"userAgent"},"userId":{"column":"userId"},"username":{"column":"username"}},"namespaceId":"public","table":"LoginAttempt"}},"Session":{"fields":{"completedEvents":{"many":{"elementNullable":false},"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"containerId":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"containerName":{"nullable":true,"type":{"codecId":"pg/text@1","kind":"scalar"}},"createdAt":{"nullable":false,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}},"finishedAt":{"nullable":true,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}},"graph":{"nullable":false,"type":{"codecId":"pg/jsonb@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}},"lastActivityAt":{"nullable":false,"type":{"codecId":"pg/timestamptz-temporal@1","kind":"scalar"}},"locale":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"quest":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"status":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"},"valueSet":{"entityKind":"enum","entityName":"SessionStatus","namespaceId":"public","plane":"domain"}},"userId":{"nullable":false,"type":{"codecId":"pg/int8@1","kind":"scalar"}}},"relations":{"commands":{"cardinality":"1:N","on":{"localFields":["id"],"targetFields":["sessionId"]},"to":{"model":"Command","namespace":"public"}},"user":{"cardinality":"N:1","nullable":false,"on":{"localFields":["userId"],"targetFields":["id"]},"to":{"model":"User","namespace":"public"}}},"storage":{"fields":{"completedEvents":{"column":"completedEvents"},"containerId":{"column":"containerId"},"containerName":{"column":"containerName"},"createdAt":{Line truncated
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@odyssey/database",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@odyssey/models": "workspace:*",
|
||||
"@prisma/cli-engine": "0.7.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^26.6.5",
|
||||
"prisma": "^8.0.0-rc.22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"contract:emit": "prisma contract emit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/orm-postgres": "^8.0.0-rc.17",
|
||||
"dotenv": "^18.0.7",
|
||||
"temporal-polyfill": "^1.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Welcome to Prisma ORM!
|
||||
|
||||
Prisma ORM lets you query your database in simple, easy-to-read TypeScript. Define what your data looks like, and Prisma ORM gives you a fully typed client — with autocomplete for every table, column, and relation.
|
||||
|
||||
This project is set up for PostgreSQL. Prisma ORM also supports other databases.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **PostgreSQL 15 or newer.** Older servers are not supported. Run `SELECT version()` against your server to verify.
|
||||
- The CLI never connects to your database without explicit consent. Pass `--probe-db` to `bun prisma orm init` if you want `init` to verify the server version itself.
|
||||
|
||||
## Your data contract
|
||||
|
||||
Your data contract is the heart of your application. It lives at [`src/prisma/contract.prisma`](src/prisma/contract.prisma) and describes your models:
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
username String?
|
||||
name String?
|
||||
}
|
||||
```
|
||||
|
||||
Every model you define in your contract can be queried from your app. Your editor will autocomplete the query methods and show you what type each model field is:
|
||||
|
||||
```typescript
|
||||
import { db } from './src/prisma/db';
|
||||
|
||||
const user = await db.orm.public.User
|
||||
.where({ email: 'alice@example.com' })
|
||||
.first();
|
||||
|
||||
// Your editor will show the type of user as
|
||||
// { id: number; email: string; username: string | null; name: string | null; createdAt: Date; posts: Post[] } | null
|
||||
```
|
||||
|
||||
Your contract has two companion files in the same directory:
|
||||
|
||||
- **`contract.json`** — this tells your application what models exist, just like `package-lock.json` tells your package manager what dependencies your project has
|
||||
- **`contract.d.ts`** — this powers autocomplete and type checking in your editor
|
||||
|
||||
Commit both files to git. When you change your contract, run `bun prisma contract emit` to update them.
|
||||
|
||||
If you use a framework like Next.js or Vite, the Prisma ORM plugin will do this for you automatically.
|
||||
|
||||
## Configuration
|
||||
|
||||
[`prisma.config.ts`](prisma.config.ts) tells the CLI where your contract lives and how to connect to your database. It loads environment variables from `.env` automatically:
|
||||
|
||||
```typescript
|
||||
import 'dotenv/config';
|
||||
import { definePrismaConfig } from '@prisma/cli-engine';
|
||||
import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
|
||||
|
||||
export default definePrismaConfig({
|
||||
orm: ormConfig({
|
||||
contract: './src/prisma/contract.prisma',
|
||||
db: {
|
||||
connection: process.env['DATABASE_URL']!,
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Notice the `DATABASE_URL` above? It's defined in your [`.env`](./.env) file:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
|
||||
```
|
||||
|
||||
You can customize how your environment variables are loaded by changing or removing the `import 'dotenv/config'` line.
|
||||
|
||||
## Quick reference
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
bun prisma contract emit # Update contract.json and contract.d.ts
|
||||
bun prisma db init # Create tables in the database
|
||||
bun prisma migration status # Show migration status
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [`src/prisma/contract.prisma`](src/prisma/contract.prisma) | Your data contract — define your models here |
|
||||
| [`prisma.config.ts`](prisma.config.ts) | CLI configuration |
|
||||
| [`src/prisma/db.ts`](src/prisma/db.ts) | Database client — `import { db } from './src/prisma/db'` |
|
||||
| `src/prisma/contract.json` | Compiled contract (generated) |
|
||||
| `src/prisma/contract.d.ts` | Contract types (generated) |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Edit [`src/prisma/contract.prisma`](src/prisma/contract.prisma) to add or change models.
|
||||
2. Run `bun prisma contract emit` to regenerate the contract.
|
||||
3. Query your models — your IDE will autocomplete everything.
|
||||
|
||||
## Monorepo notes (pnpm workspaces)
|
||||
|
||||
If this project lives inside a pnpm workspace, a few things are worth knowing:
|
||||
|
||||
- **Catalogs.** When the workspace's `pnpm-workspace.yaml` defines a `catalogs` entry for `prisma` or `@prisma/orm-postgres`, pnpm uses the catalog version everywhere — `init` does too. If you wanted the published `latest` instead, update or remove the catalog entry, then re-run `pnpm install`.
|
||||
- **`pnpm dlx`.** `pnpm dlx prisma@latest orm init …` works in any directory. Inside a workspace, pnpm still resolves dependencies through the workspace's catalog/overrides rather than the registry; expect the installed Prisma ORM packages to reflect the workspace's catalog rather than `latest`.
|
||||
- **`pnpm` → `npm` fallback.** If `pnpm` ever fails to install Prisma ORM with a `workspace:*` or `catalog:` resolution error (a leak in a published artefact), `init` falls back to `npm install` and surfaces a warning. Once the offending package republishes a clean version you can switch back with `pnpm install`.
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'dotenv/config';
|
||||
import { definePrismaConfig } from '@prisma/cli-engine';
|
||||
import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
|
||||
|
||||
export default definePrismaConfig({
|
||||
orm: ormConfig({
|
||||
contract: "./src/prisma/contract.prisma",
|
||||
db: {
|
||||
connection: process.env['DATABASE_URL']!,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* One-shot migration of the legacy Mongo data into Postgres.
|
||||
*
|
||||
* MONGO_URL=... DATABASE_URL=... bun run scripts/migrate-from-mongo.ts [--dry-run] [--force]
|
||||
*
|
||||
* - Stop the server before running; this is a snapshot copy, not a sync.
|
||||
* - Everything runs in ONE Postgres transaction, so a failure leaves Postgres untouched
|
||||
* and the script can simply be re-run.
|
||||
* - Refuses to run if any target table is non-empty, unless --force is given
|
||||
* (--force does not delete anything; it only skips the check).
|
||||
* - New ids are assigned here (sequential), so relations can be rewired from ObjectIds.
|
||||
* The id sequences are advanced afterwards.
|
||||
* - Rows that cannot be migrated (orphans, unknown providers) are written to
|
||||
* migration-skipped.json and counted in the summary.
|
||||
*/
|
||||
import 'dotenv/config'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { Temporal } from 'temporal-polyfill'
|
||||
import {
|
||||
connectDB,
|
||||
User,
|
||||
UserProfile,
|
||||
Session,
|
||||
Command,
|
||||
LoginAttempt,
|
||||
} from '@odyssey/models'
|
||||
import { db } from '../src/prisma/db'
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run')
|
||||
const FORCE = process.argv.includes('--force')
|
||||
const BATCH_SIZE = 1000
|
||||
|
||||
const LOGIN_METHODS = ['password', 'google', 'github'] as const
|
||||
type LoginMethod = (typeof LOGIN_METHODS)[number]
|
||||
const isLoginMethod = (v: string): v is LoginMethod =>
|
||||
(LOGIN_METHODS as readonly string[]).includes(v)
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
const skipped: {
|
||||
table: string
|
||||
reason: string
|
||||
id?: string
|
||||
detail?: unknown
|
||||
}[] = []
|
||||
const stats: Record<
|
||||
string,
|
||||
{ source: number; inserted: number; skipped: number }
|
||||
> = {}
|
||||
|
||||
function track(table: string) {
|
||||
return (stats[table] ??= { source: 0, inserted: 0, skipped: 0 })
|
||||
}
|
||||
|
||||
function skip(table: string, reason: string, id?: unknown, detail?: unknown) {
|
||||
track(table).skipped++
|
||||
skipped.push({
|
||||
table,
|
||||
reason,
|
||||
id: id === undefined ? undefined : String(id),
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
const instant = (d: Date) => Temporal.Instant.fromEpochMilliseconds(d.getTime())
|
||||
const instantOrNull = (d?: Date | null) => (d ? instant(d) : null)
|
||||
|
||||
// Postgres text cannot hold NUL bytes, which some captured terminal output contains.
|
||||
let nulStripped = 0
|
||||
function text(v: string): string
|
||||
function text(v: string | null | undefined): string | null
|
||||
function text(v: string | null | undefined) {
|
||||
if (v == null) return null
|
||||
if (!v.includes('\0')) return v
|
||||
nulStripped++
|
||||
return v.replaceAll('\0', '')
|
||||
}
|
||||
|
||||
// Mongoose subdocuments carry _id; the Postgres graph column stores the bare tree.
|
||||
interface GraphNode {
|
||||
path: string
|
||||
type: string
|
||||
discovered: boolean
|
||||
children?: GraphNode[]
|
||||
}
|
||||
function cleanGraph(node: any): GraphNode {
|
||||
const out: GraphNode = {
|
||||
path: node?.path ?? '/',
|
||||
// legacy sessions used 'folder' for directories
|
||||
type: node?.type === 'file' ? 'file' : 'directory',
|
||||
discovered: node?.discovered ?? false,
|
||||
}
|
||||
if (node?.children?.length) out.children = node.children.map(cleanGraph)
|
||||
return out
|
||||
}
|
||||
|
||||
// A lean Mongoose Map comes back as a plain object, a hydrated one as a Map.
|
||||
const entries = <T>(m: Map<string, T> | Record<string, T> | undefined) =>
|
||||
m instanceof Map ? [...m.entries()] : Object.entries(m ?? {})
|
||||
|
||||
async function insert(tx: Tx, table: string, plan: unknown, count: number) {
|
||||
if (DRY_RUN || count === 0) {
|
||||
track(table).inserted += count
|
||||
return
|
||||
}
|
||||
const { affectedRows } = await tx.execute(plan as never)
|
||||
track(table).inserted += Number(affectedRows)
|
||||
}
|
||||
|
||||
async function* batches<T>(cursor: AsyncIterable<T>, size: number) {
|
||||
let buf: T[] = []
|
||||
for await (const item of cursor) {
|
||||
buf.push(item)
|
||||
if (buf.length >= size) {
|
||||
yield buf
|
||||
buf = []
|
||||
}
|
||||
}
|
||||
if (buf.length) yield buf
|
||||
}
|
||||
|
||||
async function migrate(tx: Tx) {
|
||||
const t = tx.sql.public
|
||||
const userIds = new Map<string, bigint>()
|
||||
const userIdsByUsername = new Map<string, bigint>()
|
||||
const sessionIds = new Map<string, bigint>()
|
||||
const seenEmails = new Set<string>()
|
||||
const seenOAuth = new Set<string>()
|
||||
let nextUserId = 1n
|
||||
let nextSessionId = 1n
|
||||
let nextCommandId = 1n
|
||||
let nextAttemptId = 1n
|
||||
|
||||
// --- User + UserLoginMethod
|
||||
for await (const users of batches(
|
||||
User.find().sort({ _id: 1 }).lean().cursor(),
|
||||
BATCH_SIZE
|
||||
)) {
|
||||
const userRows: any[] = []
|
||||
const methodRows: any[] = []
|
||||
for (const u of users) {
|
||||
track('User').source++
|
||||
const email = u.email.trim().toLowerCase()
|
||||
if (seenEmails.has(email)) {
|
||||
throw new Error(
|
||||
`Duplicate email after lowercasing: ${email} (user ${u._id}). Resolve it in Mongo first.`
|
||||
)
|
||||
}
|
||||
seenEmails.add(email)
|
||||
|
||||
const id = nextUserId++
|
||||
userIds.set(String(u._id), id)
|
||||
userIdsByUsername.set(u.username, id)
|
||||
userRows.push({
|
||||
id,
|
||||
email,
|
||||
username: u.username,
|
||||
name: null,
|
||||
createdAt: instant(u.createdAt),
|
||||
updatedAt: instant(u.updatedAt),
|
||||
isGuest: u.isGuest ?? false,
|
||||
})
|
||||
|
||||
if (u.hashedPassword) {
|
||||
methodRows.push({
|
||||
userId: id,
|
||||
type: 'password',
|
||||
hashedPassword: u.hashedPassword,
|
||||
oauthId: null,
|
||||
displayName: null,
|
||||
createdAt: instant(u.createdAt),
|
||||
updatedAt: instant(u.updatedAt),
|
||||
})
|
||||
}
|
||||
for (const [key, login] of entries(u.socialLogins)) {
|
||||
const provider = (login.provider ?? key).toLowerCase()
|
||||
if (!isLoginMethod(provider) || provider === 'password') {
|
||||
skip('UserLoginMethod', 'unknown provider', u._id, { provider })
|
||||
continue
|
||||
}
|
||||
const dedupe = `${provider}:${login.id}`
|
||||
if (seenOAuth.has(dedupe)) {
|
||||
skip('UserLoginMethod', 'duplicate (type, oauthId)', u._id, {
|
||||
provider,
|
||||
oauthId: login.id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
seenOAuth.add(dedupe)
|
||||
methodRows.push({
|
||||
userId: id,
|
||||
type: provider,
|
||||
hashedPassword: null,
|
||||
oauthId: login.id,
|
||||
displayName: login.displayName ?? null,
|
||||
createdAt: instant(u.createdAt),
|
||||
updatedAt: instant(u.updatedAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
await insert(tx, 'User', t.User.insert(userRows).build(), userRows.length)
|
||||
track('UserLoginMethod').source += methodRows.length
|
||||
await insert(
|
||||
tx,
|
||||
'UserLoginMethod',
|
||||
t.UserLoginMethod.insert(methodRows).build(),
|
||||
methodRows.length
|
||||
)
|
||||
}
|
||||
|
||||
// --- UserProgress (UserProfile.progress map, flattened)
|
||||
for await (const profiles of batches(
|
||||
UserProfile.find().lean().cursor(),
|
||||
BATCH_SIZE
|
||||
)) {
|
||||
const rows: any[] = []
|
||||
for (const p of profiles) {
|
||||
const userId = userIds.get(String(p.user))
|
||||
const items = entries(p.progress)
|
||||
track('UserProgress').source += items.length
|
||||
if (userId === undefined) {
|
||||
for (const [quest] of items)
|
||||
skip('UserProgress', 'user not found', p.user, { quest })
|
||||
continue
|
||||
}
|
||||
for (const [key, prog] of items) {
|
||||
rows.push({
|
||||
userId,
|
||||
quest: prog.quest ?? key,
|
||||
startedAt: instant(prog.startedAt),
|
||||
// `completed` is no longer stored: finishedAt IS NOT NULL means completed.
|
||||
finishedAt: prog.completed
|
||||
? instantOrNull(prog.finishedAt ?? (p as any).updatedAt)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
await insert(
|
||||
tx,
|
||||
'UserProgress',
|
||||
t.UserProgress.insert(rows).build(),
|
||||
rows.length
|
||||
)
|
||||
}
|
||||
|
||||
// --- Session
|
||||
for await (const sessions of batches(
|
||||
Session.find().sort({ _id: 1 }).lean().cursor(),
|
||||
BATCH_SIZE
|
||||
)) {
|
||||
const rows: any[] = []
|
||||
for (const s of sessions) {
|
||||
track('Session').source++
|
||||
const userId = userIds.get(String(s.user))
|
||||
if (userId === undefined) {
|
||||
skip('Session', 'user not found', s._id, { user: String(s.user) })
|
||||
continue
|
||||
}
|
||||
const id = nextSessionId++
|
||||
sessionIds.set(String(s._id), id)
|
||||
rows.push({
|
||||
id,
|
||||
userId,
|
||||
quest: s.quest,
|
||||
// sessions created before localization have no locale; they were zh-TW
|
||||
locale: s.locale ?? 'zh-TW',
|
||||
containerId: s.containerId ?? null,
|
||||
containerName: s.containerName ?? null,
|
||||
status: s.status,
|
||||
createdAt: instant(s.createdAt),
|
||||
lastActivityAt: instant(s.lastActivityAt ?? s.updatedAt),
|
||||
finishedAt: instantOrNull(s.finishedAt),
|
||||
completedEvents: s.completedEvents ?? [],
|
||||
graph: cleanGraph(s.graph),
|
||||
})
|
||||
}
|
||||
await insert(tx, 'Session', t.Session.insert(rows).build(), rows.length)
|
||||
}
|
||||
|
||||
// --- Command (largest collection)
|
||||
for await (const commands of batches(
|
||||
Command.find().sort({ _id: 1 }).lean().cursor(),
|
||||
BATCH_SIZE
|
||||
)) {
|
||||
const rows: any[] = []
|
||||
for (const c of commands) {
|
||||
track('Command').source++
|
||||
const sessionId = sessionIds.get(String(c.session))
|
||||
if (sessionId === undefined) {
|
||||
skip('Command', 'session not found', c._id, {
|
||||
session: String(c.session),
|
||||
})
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
id: nextCommandId++,
|
||||
sessionId,
|
||||
timestamp: instant(c.createdAt),
|
||||
pwd: text(c.pwd),
|
||||
command: text(c.command),
|
||||
output: text(c.output),
|
||||
error: text(c.error),
|
||||
stage: text(c.stage),
|
||||
})
|
||||
}
|
||||
await insert(tx, 'Command', t.Command.insert(rows).build(), rows.length)
|
||||
}
|
||||
|
||||
// --- LoginAttempt (userId stays null when the username no longer exists)
|
||||
for await (const attempts of batches(
|
||||
LoginAttempt.find().sort({ _id: 1 }).lean().cursor(),
|
||||
BATCH_SIZE
|
||||
)) {
|
||||
const rows = attempts.map((a) => {
|
||||
track('LoginAttempt').source++
|
||||
return {
|
||||
id: nextAttemptId++,
|
||||
username: a.username,
|
||||
userId: userIdsByUsername.get(a.username) ?? null,
|
||||
ip: a.ip,
|
||||
loginMethod: 'password' as const, // legacy attempts were password-only
|
||||
userAgent: a.userAgent,
|
||||
time: instant(a.time ?? (a as any).createdAt),
|
||||
success: a.success,
|
||||
message: a.message ?? null,
|
||||
}
|
||||
})
|
||||
await insert(
|
||||
tx,
|
||||
'LoginAttempt',
|
||||
t.LoginAttempt.insert(rows).build(),
|
||||
rows.length
|
||||
)
|
||||
}
|
||||
|
||||
// --- Advance the autoincrement sequences past the ids we assigned explicitly.
|
||||
// Identifiers can't be bind parameters, so each statement is spelled out.
|
||||
if (!DRY_RUN) {
|
||||
const { raw } = db
|
||||
const plans = [
|
||||
raw.sql`SELECT setval(pg_get_serial_sequence('"User"', 'id'), COALESCE((SELECT MAX(id) FROM "User"), 0) + 1, false)`,
|
||||
raw.sql`SELECT setval(pg_get_serial_sequence('"Session"', 'id'), COALESCE((SELECT MAX(id) FROM "Session"), 0) + 1, false)`,
|
||||
raw.sql`SELECT setval(pg_get_serial_sequence('"Command"', 'id'), COALESCE((SELECT MAX(id) FROM "Command"), 0) + 1, false)`,
|
||||
raw.sql`SELECT setval(pg_get_serial_sequence('"LoginAttempt"', 'id'), COALESCE((SELECT MAX(id) FROM "LoginAttempt"), 0) + 1, false)`,
|
||||
]
|
||||
for (const plan of plans)
|
||||
await tx.execute(plan.affectedCount().build() as never)
|
||||
}
|
||||
}
|
||||
|
||||
async function assertTargetEmpty() {
|
||||
const { orm } = db
|
||||
const found = await Promise.all([
|
||||
orm.public.User.first(),
|
||||
orm.public.UserLoginMethod.first(),
|
||||
orm.public.UserProgress.first(),
|
||||
orm.public.Session.first(),
|
||||
orm.public.Command.first(),
|
||||
orm.public.LoginAttempt.first(),
|
||||
])
|
||||
if (found.some(Boolean)) {
|
||||
throw new Error(
|
||||
'Target tables are not empty. Truncate them or pass --force.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mongoUrl = process.env['MONGO_URL']
|
||||
if (!mongoUrl) throw new Error('MONGO_URL is not set')
|
||||
const mongo = await connectDB(mongoUrl)
|
||||
console.log(
|
||||
DRY_RUN ? 'DRY RUN: nothing will be written' : 'Migrating Mongo -> Postgres'
|
||||
)
|
||||
|
||||
try {
|
||||
if (!FORCE && !DRY_RUN) await assertTargetEmpty()
|
||||
await db.transaction((tx) => migrate(tx as Tx))
|
||||
} finally {
|
||||
await mongo.close()
|
||||
await db.close()
|
||||
}
|
||||
|
||||
console.table(stats)
|
||||
if (nulStripped)
|
||||
console.log(`Stripped NUL bytes from ${nulStripped} command text fields`)
|
||||
writeFileSync('migration-skipped.json', JSON.stringify(skipped, null, 2))
|
||||
console.log(
|
||||
`Skipped rows written to migration-skipped.json (${skipped.length})`
|
||||
)
|
||||
for (const [table, s] of Object.entries(stats)) {
|
||||
if (!DRY_RUN && s.source !== s.inserted + s.skipped) {
|
||||
throw new Error(
|
||||
`Count mismatch for ${table}: ${s.source} != ${s.inserted} + ${s.skipped}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export { db } from './prisma/db'
|
||||
|
||||
console.log('Hello via Bun!')
|
||||
+1609
File diff suppressed because it is too large.
Load diff
+1412
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,102 @@
|
||||
// use prisma-8
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement())
|
||||
email String @unique
|
||||
username String @unique
|
||||
name String?
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
isGuest Boolean
|
||||
|
||||
loginMethods UserLoginMethod[]
|
||||
progress UserProgress[]
|
||||
sessions Session[]
|
||||
loginAttempts LoginAttempt[]
|
||||
}
|
||||
|
||||
enum LoginMethod {
|
||||
@@type("pg/text@1")
|
||||
Password = "password"
|
||||
Google = "google"
|
||||
GitHub = "github"
|
||||
}
|
||||
|
||||
// User login using one of the login method
|
||||
model UserLoginMethod {
|
||||
type LoginMethod
|
||||
userId BigInt
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
hashedPassword String?
|
||||
oauthId String?
|
||||
displayName String?
|
||||
|
||||
@@id([userId, type])
|
||||
@@unique([type, oauthId])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
// Store what quest a user has completed
|
||||
model UserProgress {
|
||||
userId BigInt
|
||||
quest String
|
||||
startedAt DateTime
|
||||
finishedAt DateTime?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@id([userId, quest])
|
||||
}
|
||||
|
||||
enum SessionStatus {
|
||||
@@type("pg/text@1")
|
||||
Active = "active"
|
||||
Finished = "finished"
|
||||
Inactive = "inactive"
|
||||
}
|
||||
|
||||
model Session {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt
|
||||
quest String
|
||||
locale String
|
||||
containerId String?
|
||||
containerName String?
|
||||
status SessionStatus
|
||||
createdAt DateTime
|
||||
lastActivityAt DateTime
|
||||
finishedAt DateTime?
|
||||
completedEvents String[]
|
||||
graph Jsonb
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
commands Command[]
|
||||
|
||||
@@index([userId, quest])
|
||||
@@index([userId, status])
|
||||
}
|
||||
|
||||
model Command {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sessionId BigInt
|
||||
timestamp DateTime
|
||||
pwd String
|
||||
command String
|
||||
output String?
|
||||
error String?
|
||||
stage String?
|
||||
session Session @relation(fields: [sessionId], references: [id])
|
||||
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
model LoginAttempt {
|
||||
id BigInt @id @default(autoincrement())
|
||||
username String
|
||||
userId BigInt?
|
||||
ip String
|
||||
loginMethod LoginMethod
|
||||
userAgent String
|
||||
time DateTime
|
||||
success Boolean
|
||||
message String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
}
|
||||
Loaded 100 of 443 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user