Compare commits

..
Author SHA1 Message Date
Justin Lin 0e37b13d99 wip: not working yet... 2025-03-11 19:56:56 +08:00
Justin Lin b2a251576c fix: save stdout and stderr without tee 2025-02-22 14:07:15 +08:00
413 changed files with 15460 additions and 11921 deletions

No files matched your search

+1 -3
View File
@@ -1,6 +1,4 @@
**/node_modules **/node_modules
.git .git
**/dist **/dist
**/tsconfig.tsbuildinfo **/tsconfig.tsbuildinfo
data
postgres-data
+3 -3
View File
@@ -24,9 +24,6 @@ ADMIN_PORT=3001
# TRUSTED_PROXIES=1.2.3.4 # TRUSTED_PROXIES=1.2.3.4
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators # 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_ID=
# GOOGLE_CLIENT_SECRET= # GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID= # GITHUB_CLIENT_ID=
@@ -34,6 +31,9 @@ ADMIN_PORT=3001
# SURVEY_URL= # SURVEY_URL=
# BUG_REPORT_URL= # BUG_REPORT_URL=
# SURVEY_URL=
# BUG_REPORT_URL=
# Docker related config # Docker related config
# DOCKER_NETWORK=host # DOCKER_NETWORK=host
# QUEST_IMAGE=linuxodyssey/quest-base # QUEST_IMAGE=linuxodyssey/quest-base
+4 -4
View File
@@ -1,6 +1,6 @@
HOST=localhost HOST=localhost
PORT=3000 PORT=3000
BASE_URL=http://localhost:8000 BASE_URL=http://localhost:5173
MONGO_URL=mongodb://localhost:27017/odyssey-test MONGO_URL=mongodb://localhost:27017/odyssey-test
SECRET_KEY=sample-secret SECRET_KEY=sample-secret
DOCKER_NETWORK=linux-odyssey-players-dev DOCKER_NETWORK=linux-odyssey-players-dev
@@ -12,9 +12,6 @@ DOCKER_NETWORK=linux-odyssey-players-dev
# TRUSTED_PROXIES=1.2.3.4 # TRUSTED_PROXIES=1.2.3.4
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators # 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_ID=
# GOOGLE_CLIENT_SECRET= # GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID= # GITHUB_CLIENT_ID=
@@ -22,6 +19,9 @@ DOCKER_NETWORK=linux-odyssey-players-dev
# SURVEY_URL= # SURVEY_URL=
# BUG_REPORT_URL= # BUG_REPORT_URL=
# SURVEY_URL=
# BUG_REPORT_URL=
# Docker related config # Docker related config
# DOCKER_NETWORK=host # DOCKER_NETWORK=host
# QUEST_IMAGE=linuxodyssey/quest-base # QUEST_IMAGE=linuxodyssey/quest-base
+1 -2
View File
@@ -2,5 +2,4 @@ vite.config.js
.gitignore .gitignore
data data
mongo-init.js mongo-init.js
dist dist
postgres-data
+8 -36
View File
@@ -2,9 +2,7 @@ module.exports = {
root: true, root: true,
env: { env: {
node: true, node: true,
}, jest: true,
globals: {
Bun: 'readonly',
}, },
plugins: ['@typescript-eslint', 'cypress', 'vue'], plugins: ['@typescript-eslint', 'cypress', 'vue'],
extends: [ extends: [
@@ -22,12 +20,9 @@ module.exports = {
settings: { settings: {
'import/resolver': { 'import/resolver': {
node: { node: {
extensions: ['.js', '.ts'], extensions: ['.js', '.jsx', '.ts', '.tsx'],
moduleDirectory: ['node_modules', 'packages', 'server/src'],
},
typescript: {
alwaysTryTypes: true,
}, },
typescript: {},
}, },
vue: { vue: {
version: '3.0', // Explicitly specify Vue 3 version: '3.0', // Explicitly specify Vue 3
@@ -54,7 +49,7 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', 'error',
{ {
args: 'after-used', args: 'after-used',
argsIgnorePattern: '^_', argsIgnorePattern: '^_',
@@ -68,44 +63,21 @@ module.exports = {
// Allow missing file extensions // Allow missing file extensions
'import/extensions': [ 'import/extensions': [
'off', 'warn',
'ignorePackages', 'ignorePackages',
{ {
js: 'never', js: 'off',
jsx: 'never', jsx: 'never',
ts: 'never', ts: 'never',
tsx: '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: [ overrides: [
{ {
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
rules: { rules: {
'import/extensions': 'off', 'import/extensions': 'off', // Turn off the rule for test files
'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', 'import/no-extraneous-dependencies': 'off',
}, },
}, },
+3 -4
View File
@@ -18,9 +18,9 @@ on:
secrets: secrets:
DOCKERHUB_USERNAME: DOCKERHUB_USERNAME:
required: false required: true
DOCKERHUB_TOKEN: DOCKERHUB_TOKEN:
required: false required: true
jobs: jobs:
build-and-push: build-and-push:
@@ -42,7 +42,6 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Login to Docker Hub - name: Login to Docker Hub
if: always()
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -54,5 +53,5 @@ jobs:
context: . context: .
file: ${{ inputs.dockerfilePath }} file: ${{ inputs.dockerfilePath }}
target: ${{ inputs.target }} target: ${{ inputs.target }}
push: ${{ secrets.DOCKERHUB_USERNAME != '' }} push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: ./.github/workflows/build-docker-image.yml uses: ./.github/workflows/build-docker-image.yml
with: with:
imageName: 'linuxodyssey/analytics' imageName: 'linuxodyssey/analytics'
dockerfilePath: './services/analytics/Dockerfile' dockerfilePath: './packages/analytics/Dockerfile'
target: prod target: prod
secrets: inherit secrets: inherit
+15 -9
View File
@@ -2,6 +2,15 @@ name: CI Checks
on: on:
workflow_call: workflow_call:
secrets:
GOOGLE_CLIENT_ID:
required: true
GOOGLE_CLIENT_SECRET:
required: true
GH_CLIENT_ID:
required: true
GH_CLIENT_SECRET:
required: true
jobs: jobs:
ci: ci:
@@ -11,21 +20,18 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v2 - uses: actions/setup-node@v3
with: with:
bun-version: 1.3.14 node-version: 20
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: yarn install
- name: Type check
run: bun run type-check
- name: Build - name: Build
run: bun run build run: yarn build
- name: Lint - name: Lint
run: bun run lint run: yarn lint
- name: Test - name: Test
run: bun run test run: yarn test
-103
View File
@@ -1,103 +0,0 @@
# 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}}"
+2 -3
View File
@@ -1,7 +1,6 @@
name: E2E Tests name: E2E Tests
on: on: [push, pull_request]
workflow_dispatch:
jobs: jobs:
e2e: e2e:
@@ -14,7 +13,7 @@ jobs:
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
- name: Build Test Quests - name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend bun run build:quests run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
- name: Run Cypress Tests - name: Run Cypress Tests
env: env:
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs: jobs:
ci-checks: ci-checks:
uses: ./.github/workflows/ci-checks.yml uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build: build:
needs: ci-checks needs: ci-checks
+1
View File
@@ -10,6 +10,7 @@ concurrency:
jobs: jobs:
ci-checks: ci-checks:
uses: ./.github/workflows/ci-checks.yml uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build: build:
uses: ./.github/workflows/build.yml uses: ./.github/workflows/build.yml
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs: jobs:
ci-checks: ci-checks:
uses: ./.github/workflows/ci-checks.yml uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build: build:
needs: ci-checks needs: ci-checks
+1 -6
View File
@@ -1,8 +1,4 @@
config config
.quest
tmp
services/terminal-service/terminal-service
bin
# Logs # Logs
logs logs
@@ -144,5 +140,4 @@ dist
.pnp.* .pnp.*
#screenshots #screenshots
tests/cypress/screenshots tests/cypress/screenshots
services/cmd-hook/cmd-hook
+4 -4
View File
@@ -4,10 +4,10 @@
{ {
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"name": "File Graph Tests", "name": "File Graph Yarn Jest",
"runtimeExecutable": "bun", "runtimeExecutable": "yarn",
"args": ["test", "packages/file-graph"], "args": ["test", "--runInBand"],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}/packages/file-graph",
"console": "integratedTerminal", "console": "integratedTerminal",
"internalConsoleOptions": "neverOpen" "internalConsoleOptions": "neverOpen"
} }
+1 -7
View File
@@ -1,11 +1,5 @@
{ {
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.defaultFormatter": "esbenp.prettier-vscode", "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"
}
} }
+24 -41
View File
@@ -3,17 +3,11 @@
[![Production](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml) [![Production](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
[![Staging](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml) [![Staging](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml/badge.svg)](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 ## Requirements
- Bun: v1.3+ - NodeJS v20+
- Docker - Docker
- Yarn: v1.22
## Known Issues ## Known Issues
@@ -25,7 +19,7 @@ Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for
git clone https://github.com/lancatlin/linux-odyssey.git git clone https://github.com/lancatlin/linux-odyssey.git
cd linux-odyssey cd linux-odyssey
bun install yarn install
## Project Structures ## Project Structures
@@ -47,6 +41,10 @@ The configuration is stored in the `.env` file. You can copy from the sample fil
cp .env.sample .env cp .env.sample .env
The backend will use a SSH key pair to login to the player's container. The private key is on the host, and the public key is copied to the player's container.
The key pair is stored in `./config/ssh_key` and `./config/ssh_key.pub`. If it doesn't exist, the backend will generate a new pair and store them.
## Docker Setup ## Docker Setup
### Docker compose files explaination ### Docker compose files explaination
@@ -72,6 +70,7 @@ This will start the db. it will automatically restarts by Docker.
Building quest images: Building quest images:
docker compose build base docker compose build base
yarn build:quests
### Build Quest Images in Details ### Build Quest Images in Details
@@ -103,41 +102,40 @@ Customized `entrypoint.sh` for each quest is not yet supported.
To build all quests: To build all quests:
bun run build:quests yarn 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. 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 ### 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 improve the development experience, we can mount the host directory to the container in the pattern:
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 /path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
/path/to/linux-odyssey/packages/container:/usr/local/lib/container
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: 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 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 ## Development
Copy `.env.sample` to `.env` and customize it for your needs. Copy `.env.sample` to `.env` and customize it for your needs.
Build the whole project for the first time:
yarn build
This will build the typescript for each package and the server.
Run dev server (frontend, backend): Run dev server (frontend, backend):
bun run dev yarn dev
**IMPORTANT NOTE**: only the app and server have hot-reloading, other packages require manually running `yarn dev` in each package.
To develop analytics dashboard: To develop analytics dashboard:
bun run analytics yarn analytics
The default username and password is `alex` and `Alex1234`. The default username and password is `alex` and `Alex1234`.
@@ -145,12 +143,12 @@ The default username and password is `alex` and `Alex1234`.
Unit tests: Unit tests:
bun run test yarn test:unit
Run Cypress locally, using the current development containers: Run Cypress locally, using the current development containers:
docker compose up -d docker compose up -d
bun run test:e2e yarn test:e2e
Run Cypress in container, create brand-new containers along with it: Run Cypress in container, create brand-new containers along with it:
@@ -165,24 +163,13 @@ Run Cypress in container, create brand-new containers along with it:
## 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) # Build the images (only allowed for maintainers)
docker compose -f docker-compose.prod.yml build docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml push docker compose -f docker-compose.prod.yml push
# Server side # Server side
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
bun run build:quests yarn build:quests
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d
### Necessary Configuration ### Necessary Configuration
@@ -193,7 +180,3 @@ You can copy from `.env.sample`
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file. **YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
SECRET_KEY=your-secret-key SECRET_KEY=your-secret-key
You can generate a random key by running
bun run setup
+19 -15
View File
@@ -1,27 +1,31 @@
# Base stage # Base stage
FROM oven/bun:1.3.14-alpine AS build FROM node:20-alpine as base
WORKDIR /app WORKDIR /app
COPY package.json bun.lock ./ COPY package.json yarn.lock tsconfig.json ./
COPY app/package.json ./app/package.json
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/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 COPY packages/utils/package.json ./packages/utils/package.json
RUN bun install --frozen-lockfile --filter app COPY packages/constants/package.json ./packages/constants/package.json
COPY packages/models/package.json ./packages/models/package.json
COPY app/package.json ./app/package.json
RUN yarn install --frozen-lockfile
COPY tsconfig.json ./tsconfig.json # Dev stage
COPY server ./server FROM base as dev
COPY packages ./packages 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 app ./app COPY app ./app
RUN bun run --filter app build RUN yarn workspace app build
# Production stage # 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 --from=build /app/app/dist /usr/share/nginx/html
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
+14 -18
View File
@@ -5,8 +5,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:docker": "API_TARGET=http://backend:3000 vite", "dev:docker": "API_TARGET=http://backend:3000 vite",
"build": "vue-tsc && vite build", "build": "vue-tsc -b && vite build",
"type-check": "vue-tsc", "type-check": "vue-tsc -b",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -16,21 +16,18 @@
"@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3", "@fortawesome/vue-fontawesome": "^3.0.3",
"@odyssey/constants": "workspace:*", "@linux-odyssey/constants": "*",
"@odyssey/file-graph": "workspace:*", "@linux-odyssey/file-graph": "*",
"@odyssey/game": "workspace:*", "@linux-odyssey/models": "*",
"@odyssey/utils": "workspace:*", "@linux-odyssey/utils": "*",
"@trpc/client": "^11.1.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"axios": "^1.20.0", "axios": "^1.6.0",
"dompurify": "^3.4.16", "dompurify": "^3.0.6",
"marked": "^10.0.0", "marked": "^10.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"reconnecting-websocket": "^4.4.0",
"socket.io-client": "^4.7.1", "socket.io-client": "^4.7.1",
"validator": "^13.15.22", "validator": "^13.11.0",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-i18n": "11.3.0",
"vue-router": "^4.2.4", "vue-router": "^4.2.4",
"vue-toastification": "^2.0.0-rc.5", "vue-toastification": "^2.0.0-rc.5",
"vue3-lottie": "^3.2.0", "vue3-lottie": "^3.2.0",
@@ -40,14 +37,13 @@
}, },
"devDependencies": { "devDependencies": {
"@types/validator": "^13.11.9", "@types/validator": "^13.11.9",
"@vitejs/plugin-vue": "^5.2.2", "@vitejs/plugin-vue": "^4.5.2",
"@vue/compiler-sfc": "^3.3.13", "@vue/compiler-sfc": "^3.3.13",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.5.23", "postcss": "^8.4.31",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "~5.7.2", "typescript": "^5.3.3",
"vite": "^6.4.3", "vite": "^4.5.3",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.0.10"
} }
} }
+20 -32
View File
@@ -1,13 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import api from '../utils/api' import api from '../utils/api'
const { t } = useI18n()
const emit = defineEmits(['onSubmit', 'onChange']) const emit = defineEmits(['onSubmit', 'onChange'])
defineProps({ defineProps({
type: { type: {
type: String, type: String,
@@ -113,7 +110,7 @@ const registerGuest = async () => {
:icon="['fas', 'circle-exclamation']" :icon="['fas', 'circle-exclamation']"
class="px-1 text-sm" class="px-1 text-sm"
/> />
<span>{{ t('authform.recommand_pc_fullscreen') }}</span> <span>建議使用電腦全螢幕執行</span>
</div> </div>
<form @submit.prevent="handleSubmit()" class="w-full"> <form @submit.prevent="handleSubmit()" class="w-full">
<div v-if="socialLogin && hasSocialLogins"> <div v-if="socialLogin && hasSocialLogins">
@@ -127,7 +124,7 @@ const registerGuest = async () => {
:icon="['fab', 'google']" :icon="['fab', 'google']"
class="text-text-secondary px-3 text-lg" class="text-text-secondary px-3 text-lg"
/> />
<span class="font-medium"> {{ t('authform.google_login') }} </span> <span class="font-medium"> 以 Google 繼續 </span>
</a> </a>
<a <a
id="GitHubLogin" id="GitHubLogin"
@@ -139,14 +136,14 @@ const registerGuest = async () => {
:icon="['fab', 'github']" :icon="['fab', 'github']"
class="text-text-secondary px-3 text-lg" class="text-text-secondary px-3 text-lg"
/> />
<span class="font-medium"> {{ t('authform.github_login') }} </span> <span class="font-medium"> 以 GitHub 繼續 </span>
</a> </a>
<p <p
id="or" id="or"
class="text-text-secondary flex justify-center mt-3" class="text-text-secondary flex justify-center mt-3"
v-if="type === 'login' || type === 'register'" v-if="type === 'login' || type === 'register'"
> >
{{ t('authform.or') }} 或
</p> </p>
</div> </div>
<div class="mb-6"> <div class="mb-6">
@@ -155,22 +152,20 @@ const registerGuest = async () => {
id="username" id="username"
ref="usernameInput" 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" 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="type === 'login' ? '電子郵件 / 帳號名稱' : '帳號名稱'"
t('authform.username') +
(type === 'login' ? ' / ' + t('authform.email') : '')
"
v-model="username" v-model="username"
required required
@input="handleChange()" @input="handleChange()"
autocomplete="username" autocomplete="username"
/> />
<p <label
class="mx-1 text-xs font-normal text-text-secondary" class="text-sm font-normal text-text-secondary"
id="NameRules" id="NameRules"
v-if="type === 'register'" v-if="type === 'username' || type === 'register'"
> >
{{ t('authform.username_rules') }} <p>可用小寫字母、數字、"_"和"-"</p>
</p> <p>小寫字母開頭,至多32字元</p>
</label>
</div> </div>
<div class="mb-6" v-if="type === 'register'"> <div class="mb-6" v-if="type === 'register'">
<input <input
@@ -178,7 +173,7 @@ const registerGuest = async () => {
id="email" id="email"
ref="emailInput" 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" 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="t('authform.email')" placeholder="電子郵件"
v-model="email" v-model="email"
required required
@input="handleChange()" @input="handleChange()"
@@ -190,18 +185,11 @@ const registerGuest = async () => {
type="password" type="password"
id="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" 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="t('authform.password')" placeholder="密碼"
v-model="password" v-model="password"
required required
@input="handleChange()" @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> </div>
<p <p
id="ErrorDisplay" id="ErrorDisplay"
@@ -215,29 +203,29 @@ const registerGuest = async () => {
class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full" class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full"
type="submit" type="submit"
> >
<span v-if="type === 'login'"> {{ t('authform.login') }} </span> <span v-if="type === 'login'"> 登入 </span>
<span v-else> {{ t('authform.register') }} </span> <span v-else> 註冊 </span>
</button> </button>
<p id="SwitchLoginRegister" class="text-text flex justify-center mt-3"> <p id="SwitchLoginRegister" class="text-text flex justify-center mt-3">
<span id="CheckRegistered" v-if="type === 'register'"> <span id="CheckRegistered" v-if="type === 'register'">
{{ t('authform.already_registered') }} 已經有帳號了嗎?
<RouterLink <RouterLink
id="SwitchtoLogin" id="SwitchtoLogin"
class="text-text-primary font-bold" class="text-text-primary font-bold"
to="/login" to="/login"
> >
<u> {{ t('authform.login') }} </u> <u> 登入 </u>
</RouterLink></span </RouterLink></span
> >
<span id="CheckRegistered" v-else-if="type === 'login'"> <span id="CheckRegistered" v-else-if="type === 'login'">
{{ t('authform.not_registered') }} 還沒有帳號嗎?
<RouterLink <RouterLink
id="SwitchtoRegister" id="SwitchtoRegister"
class="text-text-primary font-bold" class="text-text-primary font-bold"
to="/register" to="/register"
> >
<u> {{ t('authform.register') }} </u> <u> 註冊 </u>
</RouterLink></span </RouterLink></span
> >
</p> </p>
@@ -246,7 +234,7 @@ const registerGuest = async () => {
class="rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2" class="rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
@click="registerGuest()" @click="registerGuest()"
> >
{{ t('authform.continue_as_guest') }} 以訪客身分繼續
</button> </button>
</form> </form>
</div> </div>
+1 -1
View File
@@ -22,7 +22,7 @@ function nodeClass() {
<template> <template>
<a href="#" :class="nodeClass()"> <a href="#" :class="nodeClass()">
<font-awesome-icon <font-awesome-icon
v-if="node.type === 'directory'" v-if="node.type === 'folder'"
:icon="['far', 'folder']" :icon="['far', 'folder']"
class="text-text-primary size-8" class="text-text-primary size-8"
/> />
+4 -24
View File
@@ -31,31 +31,11 @@ const html = computed(() => {
.markdown-content :deep(ol) { .markdown-content :deep(ol) {
list-style: decimal; list-style: decimal;
padding-left: 1em; padding-left: 1em;
line-height: 1.2;
} }
.markdown-content :deep(ul) { .markdown-content :deep(code) {
list-style: disc; background-color: #282a33;
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; border-radius: 0.25rem;
padding: 0.2rem 0.4rem; padding: 0.25rem;
font-size: 0.9rem; font-size: 0.875rem;
color: #d8dee9;
font-family: 'monospace';
} }
</style> </style>
+7 -17
View File
@@ -1,12 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { ref, defineProps, onMounted } from 'vue' import { ref, defineProps, onMounted } from 'vue'
import { getQuest } from '../utils/api'
import MarkdownText from './MarkdownText.vue' import MarkdownText from './MarkdownText.vue'
import { trpc } from '../utils/trpc'
const { t, locale } = useI18n()
const props = defineProps({ const props = defineProps({
questTitle: { questTitle: {
@@ -39,7 +36,7 @@ const handleQuests = (id: string) => {
if (questUnlocked) { if (questUnlocked) {
router.push({ name: 'game', params: { questId: id } }) router.push({ name: 'game', params: { questId: id } })
} else { } else {
toast.warning(t('quest.locked')) toast.warning('你還沒完成前一個關卡!')
} }
} }
const handleColour = () => { const handleColour = () => {
@@ -52,15 +49,8 @@ const handleColour = () => {
} }
} }
onMounted(async () => { onMounted(async () => {
try { const quest = await getQuest(questId)
const quest = await trpc.quests.getQuestDetail.query({ questInstruction.value = quest.instruction
questId,
locale: locale.value,
})
questInstruction.value = quest.instruction
} catch (error) {
toast.error(t('quest.load_quest_instruction_failed'), error)
}
handleColour() handleColour()
}) })
</script> </script>
@@ -71,6 +61,7 @@ onMounted(async () => {
<span class="close text-text content-between" @click="closeModal" <span class="close text-text content-between" @click="closeModal"
>&times;</span >&times;</span
> >
<h2 <h2
id="QuestTitle" id="QuestTitle"
class="text-2xl text-text mb-5 w-full inline-flex justify-center" class="text-2xl text-text mb-5 w-full inline-flex justify-center"
@@ -90,10 +81,9 @@ onMounted(async () => {
target="_blank" target="_blank"
@click="handleQuests(questId)" @click="handleQuests(questId)"
:style="{ backgroundColor: questColor, color: questTextColor }" :style="{ backgroundColor: questColor, color: questTextColor }"
class="inline-flex justify-center rounded-lg text-base font-black py-3 mt-5 w-full" class="inline-flex justify-center rounded-lg text-base font-black py-3 mt-5 w-full text-lg"
>開始冒險</a
> >
{{ t('quest.start') }}
</a>
</div> </div>
</div> </div>
</template> </template>
+94 -94
View File
@@ -1,22 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref, onMounted, watch, computed } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { DAG } from '@odyssey/utils' import { DAG } from '@linux-odyssey/utils'
import { trpc } from '../utils/trpc' import { getQuests } from '../utils/api'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
import QuestIntro from './QuestIntro.vue' import QuestIntro from './QuestIntro.vue'
const { t, locale } = useI18n()
const store = useUserProfile() const store = useUserProfile()
const toast = useToast() const toast = useToast()
const svgWidth = ref(window.innerWidth)
const svgHeight = ref(window.innerHeight)
const marginX = 120 const marginX = 120
const marginY = 120 const marginY = 120
const nodeWidth = 160 const nodeWidth = 160
const nodeHeight = 50 const nodeHeight = 50
const opened = ref<Node | null>(null) const opened = ref<Node | null>(null)
type Node = { type Node = {
id: string id: string
@@ -44,18 +44,14 @@ const dragStartY = ref(0)
const offsetX = ref(0) const offsetX = ref(0)
const offsetY = ref(0) const offsetY = ref(0)
function startDrag(event: PointerEvent) { function startDrag(event: MouseEvent) {
isDragging.value = true isDragging.value = true
dragStartX.value = event.clientX - offsetX.value dragStartX.value = event.clientX - offsetX.value
dragStartY.value = event.clientY - offsetY.value dragStartY.value = event.clientY - offsetY.value
} }
function drag(event: PointerEvent) { function drag(event: MouseEvent) {
if (!isDragging.value) return 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 offsetX.value = event.clientX - dragStartX.value
offsetY.value = event.clientY - dragStartY.value offsetY.value = event.clientY - dragStartY.value
} }
@@ -68,18 +64,19 @@ async function computeGraphData() {
if (!store.progress) return if (!store.progress) return
try { try {
const quests = await trpc.quests.getQuests.query({ locale: locale.value }) const quests = await getQuests()
const dag = new DAG(quests) const dag = new DAG(quests)
const nodesValues = dag.getNodes() const nodesValues = dag.getNodes()
const nodes = nodesValues.map((node) => ({ const nodes = nodesValues.map((node) => ({
id: node.id, id: node._id,
title: node.title, title: node.title,
x: marginX * node.layer * 2, x: marginX * node.layer * 2,
// Centered on 0; the world container is anchored at the viewport's vertical center y:
y: (node.index - (dag.getLayer(node.id) + 1) / 2) * marginY, marginY * node.index -
completed: store.progress[node.id]?.completed || false, (marginX * dag.getLayer(node._id)) / 2 +
svgHeight.value / 2,
completed: store.progress[node._id]?.completed || false,
unlocked: node.requirements.every( unlocked: node.requirements.every(
(req: string) => store.progress[req]?.completed (req: string) => store.progress[req]?.completed
), ),
@@ -99,8 +96,8 @@ async function computeGraphData() {
graphData.value = { nodes, edges } graphData.value = { nodes, edges }
} catch (error) { } catch (error) {
console.error(t('quest_map.compute_graph_failed'), error) console.error('Error computing graph data:', error)
toast.error(t('quest_map.load_quest_data_failed')) toast.error('Failed to load quest data')
} }
} }
function handleNodeClick(node: Node) { function handleNodeClick(node: Node) {
@@ -118,15 +115,15 @@ onMounted(async () => {
}) })
watch(() => store.progress, computeGraphData, { deep: true }) watch(() => store.progress, computeGraphData, { deep: true })
watch(locale, () => {
opened.value = null
computeGraphData()
})
const edgePath = computed(() => { const curvedPath = computed(() => {
return (source: Node, target: Node) => { return (source: Node, target: Node) => {
const mx = (source.x + target.x) / 2 // const dx = target.x - source.x
return `M${source.x},${source.y}C${mx},${source.y} ${mx},${target.y} ${target.x},${target.y}` // 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}`
} }
}) })
@@ -154,46 +151,54 @@ const edgeStyle = computed(() => {
class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl" 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" style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
> >
{{ t('quest_map.intro') }} 踏上你的Linux冒險之旅吧!
</h1> </h1>
<div <svg
class="absolute inset-0 overflow-hidden select-none" :width="svgWidth"
:class="isDragging ? 'cursor-grabbing' : 'cursor-grab'" :height="svgHeight"
style="touch-action: none" class="absolute"
@pointerdown="startDrag" @mousedown="startDrag"
@pointermove="drag" @mousemove="drag"
@pointerup="endDrag" @mouseup="endDrag"
@pointercancel="endDrag" @mouseleave="endDrag"
> >
<div <g :transform="`translate(${offsetX}, ${offsetY})`">
class="absolute top-1/2 left-0" <g
:style="{ transform: `translate(${offsetX}px, ${offsetY}px)` }" v-for="edge in graphData.edges"
> :key="`${edge.source.id}-${edge.target.id}`"
<svg class="absolute overflow-visible pointer-events-none"> >
<path <path
v-for="edge in graphData.edges" :d="curvedPath(edge.source, edge.target)"
:key="`${edge.source.id}-${edge.target.id}`"
:d="edgePath(edge.source, edge.target)"
:class="['edge', edgeStyle(edge)]" :class="['edge', edgeStyle(edge)]"
/> />
</svg> </g>
<button <g
v-for="node in graphData.nodes" v-for="node in graphData.nodes"
:key="node.id" :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)" @click="handleNodeClick(node)"
> >
<span class="line-clamp-2">{{ node.title }}</span> <rect
</button> :x="node.x - nodeWidth / 2"
</div> :y="node.y - nodeHeight / 2"
</div> :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>
<QuestIntro <QuestIntro
:questTitle="opened.title" :questTitle="opened.title"
:questId="opened.id" :questId="opened.id"
@@ -206,49 +211,44 @@ const edgeStyle = computed(() => {
</template> </template>
<style scoped> <style scoped>
svg {
overflow: visible;
}
.node { .node {
position: absolute; &.completed {
display: flex; fill: #6cf76c;
align-items: center; }
justify-content: center; &.unlocked {
padding: 0 0.5rem; fill: #8c8c92;
text-align: center; }
border-radius: 10px; &.locked {
cursor: pointer; fill: #505050;
transition: }
filter 0.15s,
box-shadow 0.15s;
} }
.node:hover {
filter: brightness(1.15); .node-text {
} &.completed {
.node:focus-visible { fill: #1d1d1d;
outline: none; }
box-shadow: 0 0 0 3px #00ff00; &.unlocked {
} fill: #ffffff;
.node.completed { }
background-color: #6cf76c; &.locked {
color: #1d1d1d; fill: #a0a0a0;
} }
.node.unlocked {
background-color: #8c8c92;
color: #ffffff;
}
.node.locked {
background-color: #505050;
color: #a0a0a0;
} }
.edge { .edge {
stroke-width: 3; stroke-width: 3;
fill: none; fill: none;
} &.unlocked {
.edge.unlocked { stroke: #adadb5;
stroke: #adadb5; stroke-dasharray: none;
stroke-dasharray: none; }
} &.locked {
.edge.locked { stroke: #454552;
stroke: #454552; stroke-dasharray: 5, 5;
stroke-dasharray: 5, 5; }
} }
</style> </style>
+2 -10
View File
@@ -1,15 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template> <template>
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" /> <font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
<h1 id="CommandBook" class="inline text-text-primary font-xl"> <h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1>
{{ t('game.command_book') }}
</h1>
<div id="cheatsheets"> <div id="cheatsheets">
<p class="text-text-secondary">-- --{{ t('game.cheatsheets') }}-- --</p> <p class="text-text-secondary">-- --指令列表-- --</p>
</div> </div>
</template> </template>
+5 -10
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { ref } from 'vue' import { ref } from 'vue'
@@ -7,8 +6,6 @@ import { Vue3Lottie } from 'vue3-lottie'
// import { surveyUrl } from '../config' // import { surveyUrl } from '../config'
import CompleteAnimation from '../../img/complete-animation.json' import CompleteAnimation from '../../img/complete-animation.json'
const { t } = useI18n()
const showModal = ref(true) // Controls whether the modal is visible or not const showModal = ref(true) // Controls whether the modal is visible or not
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -32,7 +29,7 @@ const backtoMap = async () => {
try { try {
router.push({ name: 'map' }) router.push({ name: 'map' })
} catch (err) { } catch (err) {
toast.error(t('quest_map.load_quest_map_failed')) toast.error('無法讀取地圖')
console.error(err) console.error(err)
} }
} }
@@ -52,7 +49,7 @@ setTimeout(() => {
id="QuestCompleted" id="QuestCompleted"
class="text-2xl text-text mb-5 w-full inline-flex justify-center" class="text-2xl text-text mb-5 w-full inline-flex justify-center"
> >
{{ t('game.quest_completed') }} 關卡完成!
</h2> </h2>
<div class="z-10 right-1/2 top-1/2" @animationend="defineComponent"> <div class="z-10 right-1/2 top-1/2" @animationend="defineComponent">
<Vue3Lottie <Vue3Lottie
@@ -61,18 +58,16 @@ setTimeout(() => {
:width="200" :width="200"
/> />
</div> </div>
<p class="text-base text-text text-center">恭喜你完成這一關!</p>
<p class="text-base text-text text-center"> <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> </p>
<a <a
id="BacktoMap" id="BacktoMap"
target="_blank" target="_blank"
@click="backtoMap" @click="backtoMap"
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full" class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
>{{ t('game.back_to_map') }}</a >回到地圖</a
> >
</div> </div>
<div style="position: relative; height: 100vh; overflow: auto"> <div style="position: relative; height: 100vh; overflow: auto">
+10 -56
View File
@@ -1,62 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import type { QuestLocale } from '@odyssey/constants' import useSession from '../../store/session'
import { useSession } from '../../store/session'
import { openBugReport, openQuestSurvey } from '../../utils/formbricks' import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
import { trpc } from '../../utils/trpc'
const { t, locale } = useI18n()
const toast = useToast()
const router = useRouter() const router = useRouter()
const sessionStore = useSession() const sessionStore = useSession()
const reset = async () => { const reset = async () => {
console.log('resetting...') console.log('resetting...')
const selectedLocale = locale.value as QuestLocale await sessionStore.createSession()
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 () => { const continuePlay = async () => {
try { try {
router.push({ name: 'map' }) router.push({ name: 'map' })
} catch (err) { } catch (err) {
useToast().error(t('quest_map.load_quest_map_failed')) useToast().error('無法讀取地圖')
console.error(err) console.error(err)
} }
} }
</script> </script>
<template> <template>
<section class="mt-2 flex flex-row gap-2 justify-center"> <section class="mt-2 flex flex-row gap-3 justify-center">
<button <button
id="survey" id="survey"
:title="t('game_nav.survey')"
class="bg-bg-secondary rounded-lg p-2" class="bg-bg-secondary rounded-lg p-2"
@click="openQuestSurvey" @click="openQuestSurvey"
> >
@@ -64,18 +31,15 @@ const continuePlay = async () => {
:icon="['fas', 'file-invoice']" :icon="['fas', 'file-invoice']"
class="text-text-primary px-1" class="text-text-primary px-1"
/> />
<p class="text-text-primary text-xs">{{ t('game_nav.survey') }}</p> <p class="text-text-primary inline md:flex">問卷</p>
</button> </button>
<button <button
id="bug-report" title="Bug Report"
:title="t('game_nav.bug_report')"
@click="openBugReport" @click="openBugReport"
class="bg-bg-secondary rounded-lg p-2" class="bg-bg-secondary rounded-lg p-2"
> >
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" /> <font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
<p class="text-text-primary text-xs"> <p class="text-text-primary inline ml-1 md:flex">回報</p>
{{ t('game_nav.bug_report') }}
</p>
</button> </button>
<!-- <button disabled id="solution" class="bg-bg-disabled rounded-lg p-2"> <!-- <button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
<font-awesome-icon <font-awesome-icon
@@ -84,23 +48,15 @@ const continuePlay = async () => {
/> />
<p class="text-text-disabled inline ml-1 md:flex">解答</p> <p class="text-text-disabled inline ml-1 md:flex">解答</p>
</button> --> </button> -->
<button <button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
id="reset"
:title="t('game.reset')"
class="bg-bg-secondary rounded-lg p-2"
@click="reset"
>
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'arrow-rotate-left']" :icon="['fas', 'arrow-rotate-left']"
class="text-text-primary px-1" class="text-text-primary px-1"
/> />
<p class="text-text-primary text-xs"> <p class="text-text-primary inline ml-1 md:flex">重來</p>
{{ t('game.reset') }}
</p>
</button> </button>
<button <button
id="continue" id="continue"
:title="t('game.continue')"
class="bg-bg-secondary rounded-lg p-2" class="bg-bg-secondary rounded-lg p-2"
@click="continuePlay" @click="continuePlay"
> >
@@ -108,9 +64,7 @@ const continuePlay = async () => {
:icon="['far', 'circle-right']" :icon="['far', 'circle-right']"
class="text-text-primary px-1" class="text-text-primary px-1"
/> />
<p class="text-text-primary text-xs"> <p class="text-text-primary inline ml-1 md:flex">繼續</p>
{{ t('game.continue') }}
</p>
</button> </button>
</section> </section>
</template> </template>
+11 -34
View File
@@ -1,16 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useSession } from '../../store/session' import useSession from '../../store/session'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from '../MarkdownText.vue'
const { t } = useI18n()
const sessionStore = useSession() const sessionStore = useSession()
const current = ref(-1) const current = ref(-1)
const hintDetails = ref<HTMLDetailsElement | null>(null)
const length = computed(() => { const length = computed(() => {
return sessionStore.session?.responses?.length ?? 0 return sessionStore.session?.responses.length ?? 0
}) })
const disabled = computed(() => { const disabled = computed(() => {
@@ -18,7 +14,6 @@ const disabled = computed(() => {
}) })
watch(length, () => { watch(length, () => {
console.log('length update', length.value)
current.value = length.value - 1 current.value = length.value - 1
}) })
@@ -33,19 +28,6 @@ const right = () => {
current.value += 1 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> </script>
<template> <template>
@@ -55,9 +37,7 @@ watch(response, () => {
:icon="['far', 'lightbulb']" :icon="['far', 'lightbulb']"
class="text-yellow-200 p-2 content-center" class="text-yellow-200 p-2 content-center"
/> />
<h1 class="inline text-text w-1/2 font-xl p-2 m-1"> <h1 class="inline text-text w-1/2 font-xl p-2 m-1">說明</h1>
{{ t('game.description') }}
</h1>
<div v-if="current !== -1" class="flex w-full justify-end items-end"> <div v-if="current !== -1" class="flex w-full justify-end items-end">
<button <button
class="p-2 m-1 w-1/8" class="p-2 m-1 w-1/8"
@@ -82,30 +62,27 @@ watch(response, () => {
@click="right" @click="right"
:disabled="disabled" :disabled="disabled"
> >
<font-awesome-icon <font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
:icon="['fas', 'arrow-right']"
:class="disabled ? 'text-gray-600' : 'text-text'"
/>
</button> </button>
</div> </div>
</div> </div>
<div id="hint" class="bg-bg p-8 overflow-y-auto"> <div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul> <ul>
<li <li
v-if="response" v-for="response in sessionStore.session?.responses[current]"
:key="response.type" :key="response.type"
class="text-text font-xl whitespace-pre-wrap" class="text-text font-xl whitespace-pre-wrap"
> >
<MarkdownText :content="response.content" /> <div v-for="content in response.content" :key="content">
<MarkdownText :content="content" />
</div>
</li> </li>
<li <li
v-if="response?.hint" v-for="hint in sessionStore.session?.hints[current]"
:key="hint"
class="text-text-primary font-xl whitespace-pre-wrap" class="text-text-primary font-xl whitespace-pre-wrap"
> >
<details ref="hintDetails"> <MarkdownText :content="hint" />
<summary class="cursor-pointer">{{ t('game.hint') }}</summary>
<MarkdownText :content="response.hint" />
</details>
</li> </li>
</ul> </ul>
<br /> <br />
+5 -8
View File
@@ -1,15 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed } from 'vue' import { computed } from 'vue'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from '../MarkdownText.vue'
import { useSession } from '../../store/session' import useSession from '../../store/session'
import { Task } from '../../types'
const { t } = useI18n()
const sessionStore = useSession() const sessionStore = useSession()
const tasks = computed(() => { const tasks = computed(() => {
console.log('session', sessionStore.session) return sessionStore.session?.tasks.map((task: Task) => {
return sessionStore.session?.tasks.map((task) => {
const prefix = task.completed ? '✓' : '➤' const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text' const color = task.completed ? 'text-text-primary' : 'text-text'
return { return {
@@ -34,14 +31,14 @@ const tasks = computed(() => {
:content="sessionStore.quest.instruction" :content="sessionStore.quest.instruction"
/> />
<br /> <br />
<p id="tasks" class="text-text font-bold">{{ t('game.task') }}</p> <p id="tasks" class="text-text">任務:</p>
<ul v-if="sessionStore.session"> <ul v-if="sessionStore.session">
<li v-for="task in tasks" :key="task.id"> <li v-for="task in tasks" :key="task.id">
<MarkdownText :class="task.class" :content="task.content" /> <MarkdownText :class="task.class" :content="task.content" />
</li> </li>
</ul> </ul>
</div> </div>
<p class="text-text" v-else>{{ t('game.loading') }}</p> <p class="text-text" v-else>Loading...</p>
</div> </div>
</div> </div>
</template> </template>
+2 -5
View File
@@ -1,10 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { computed } from 'vue' import { computed } from 'vue'
import { useSession } from '../../store/session' import useSession from '../../store/session'
const { t } = useI18n()
const toast = useToast() const toast = useToast()
const sessionStore = useSession() const sessionStore = useSession()
@@ -34,7 +31,7 @@ const startSession = async () => {
:icon="['fas', 'play']" :icon="['fas', 'play']"
class="text-text-primary h-full px-3" class="text-text-primary h-full px-3"
/> />
<p class="text-text-primary inline ml-1 md:flex">{{ t('game.start') }}</p> <p class="text-text-primary inline ml-1 md:flex">開始</p>
</button> </button>
</div> </div>
</template> </template>
+11 -35
View File
@@ -1,47 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue' import { useTerminal } from '../../store/session'
import { useSession, useTerminal } from '../../store/session'
const { t } = useI18n()
const sessionStore = useSession()
const terminal = ref<HTMLElement | null>(null) const terminal = ref<HTMLElement | null>(null)
const term = useTerminal()
onMounted(() => { onMounted(() => {
const term = useTerminal()
if (!terminal.value) return if (!terminal.value) return
term.mount(terminal.value) term.mount(terminal.value)
window.addEventListener('resize', () => term.resizeScreen())
const handleResize = () => term.resizeScreen()
window.addEventListener('resize', handleResize)
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
}) })
</script> </script>
<template> <template>
<div class="flex flex-col h-full"> <font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
<div class="flex items-center gap-2 p-1"> <button id="Terminal" class="text-text h-[5%]">終端機</button>
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text" /> <div
<p id="Terminal" class="text-text">{{ t('game.terminal') }}</p> id="terminal"
ref="terminal"
<a class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
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" ></div>
: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> </template>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import FileNode from '../FileNode.vue' import FileNode from '../FileNode.vue'
import { useSession } from '../../store/session' import useSession from '../../store/session'
const sessionStore = useSession() const sessionStore = useSession()
</script> </script>
@@ -1,15 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { logout } from '../../utils/auth' import { logout } from '../../utils/auth'
import { trpc } from '../../utils/trpc' import { surveyUrl } from '../../config'
import { openBugReport } from '../../utils/formbricks' import { openBugReport } from '../../utils/formbricks'
import { useSession } from '../../store/session' import useSession from '../../store/session'
import useUserProfile from '../../store/userProfile' import useUserProfile from '../../store/userProfile'
const { t } = useI18n()
const userStore = useUserProfile() const userStore = useUserProfile()
const sessionStore = useSession() const sessionStore = useSession()
const router = useRouter() const router = useRouter()
@@ -23,21 +21,16 @@ const handleLogout = async () => {
sessionStore.reset() sessionStore.reset()
router.push({ name: 'login' }) router.push({ name: 'login' })
} catch (err) { } catch (err) {
toast.error(t('game_nav.sign_out_failed')) toast.error('登出失敗')
console.error(err) console.error(err)
} }
} }
const openSurvey = async () => {
const surveyUrl = await trpc.links.surveyLink.query()
window.open(surveyUrl, '_blank')
}
onMounted(async () => { onMounted(async () => {
try { try {
await userStore.loadUserProfile() await userStore.loadUserProfile()
} catch (err) { } catch (err) {
toast.error(t('game_nav.load_user_profile_failed')) toast.error('無法讀取使用者資料')
console.error(err) console.error(err)
} }
}) })
@@ -68,46 +61,47 @@ onMounted(async () => {
> >
{{ userStore.username }} {{ userStore.username }}
</p> </p>
<button <a
:title="t('game_nav.survey')" title="Survey"
:href="surveyUrl"
target="_blank" target="_blank"
class="md:h-5 md:w-5 px-1.5 w-auto" class="md:h-5 md:w-5 px-1.5 w-auto"
@click="openSurvey"
> >
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'file-invoice']" :icon="['fas', 'file-invoice']"
class="text-text-primary inline" class="text-text-primary inline"
/> />
</button> <span class="text-text sm:hidden px-2">Survey</span>
</a>
<RouterLink <RouterLink
:title="t('game_nav.leaderboard')" title="LeaderBoard"
to="/leaderboard" to="/leaderboard"
class="md:h-5 md:w-5 px-1.5 w-auto" class="md:h-5 md:w-5 px-1.5 w-auto"
> >
<font-awesome-icon :icon="['fas', 'trophy']" class="text-text-primary" /> <font-awesome-icon :icon="['fas', 'trophy']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Leader Board</span>
</RouterLink> </RouterLink>
<RouterLink <RouterLink title="Map" to="/map" class="md:h-5 md:w-5 px-1.5 w-auto">
: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" /> <font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Map</span>
</RouterLink> </RouterLink>
<button <button
:title="t('game_nav.bug_report')" title="Bug Report"
@click="openBugReport" @click="openBugReport"
class="md:h-5 md:w-5 px-1.5 w-auto" class="md:h-5 md:w-5 px-1.5 w-auto"
> >
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" /> <font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Bug Report</span>
</button> </button>
<button <button
:title="t('game_nav.sign_out')" title="Sign Out"
@click="handleLogout" @click="handleLogout"
class="md:h-5 md:w-5 px-1.5 mr-2 w-auto" class="md:h-5 md:w-5 px-1.5 w-auto"
> >
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'arrow-right-from-bracket']" :icon="['fas', 'arrow-right-from-bracket']"
class="text-text-primary" class="text-text-primary"
/> />
<span class="text-text sm:hidden px-2">Sign Out</span>
</button> </button>
</template> </template>
+2 -8
View File
@@ -1,10 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { defineProps, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import LanguageSelector from './LanguageSelector.vue'
const { t } = useI18n()
defineProps({ defineProps({
title: { title: {
@@ -24,7 +19,7 @@ const toggleMenu = () => {
</script> </script>
<template> <template>
<div <div
class="w-full top-0 px-2 fixed flex flex-row flex-wrap bg-bg-secondary justify-between items-center sm:justify-between" class="w-full top-0 px-5 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 }"> <div class="flex flex-row items-center gap-3" :class="{ hidden: menuOpen }">
<img src="../../img/icon_totem.svg" class="h-8 justify-self-start" /> <img src="../../img/icon_totem.svg" class="h-8 justify-self-start" />
@@ -49,7 +44,6 @@ const toggleMenu = () => {
:class="{ hidden: !menuOpen }" :class="{ hidden: !menuOpen }"
> >
<component :is="headerComponent" /> <component :is="headerComponent" />
<LanguageSelector />
</div> </div>
<div <div
v-if="windowWidth" v-if="windowWidth"
@@ -1,9 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { bugReportUrl } from '../../config' import { bugReportUrl } from '../../config'
const { t } = useI18n()
const scrollToSection = (sectionId: string) => { const scrollToSection = (sectionId: string) => {
const section = document.getElementById(sectionId) const section = document.getElementById(sectionId)
if (section) { if (section) {
@@ -24,25 +21,22 @@ const scrollToSection = (sectionId: string) => {
@click="scrollToSection('intro')" @click="scrollToSection('intro')"
style="font-size: 2vh" style="font-size: 2vh"
> >
{{ t('landing_nav.intro') }} 前言
</button> </button>
<button <button
id="Feature" id="Feature"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary" class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('feature')" @click="scrollToSection('feature')"
style="font-size: 2vh" style="font-size: 2vh"
> >
{{ t('landing_nav.features') }} 特色</button
</button> ><button
<button
id="Info" id="Info"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary" class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('about')" @click="scrollToSection('about')"
style="font-size: 2vh" style="font-size: 2vh"
> >
{{ t('landing_nav.about') }} 關於
</button> </button>
<button <button
@@ -51,27 +45,18 @@ const scrollToSection = (sectionId: string) => {
@click="scrollToSection('contact')" @click="scrollToSection('contact')"
style="font-size: 2vh" style="font-size: 2vh"
> >
{{ t('landing_nav.contact') }} 聯絡我們
</button> </button>
<RouterLink <RouterLink
title="Sign In"
id="SignIn" id="SignIn"
to="/register" to="/register"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary" class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
style="font-size: 2vh" style="font-size: 2vh"
> >
{{ t('landing_nav.login') }} 登入
</RouterLink> </RouterLink>
<a title="Bug Report" :href="bugReportUrl" target="_blank" class="h-5 w-5">
<a <font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
: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> </a>
</template> </template>
@@ -1,30 +0,0 @@
<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>
-39
View File
@@ -1,39 +0,0 @@
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
}
-116
View File
@@ -1,116 +0,0 @@
{
"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"
}
}
-116
View File
@@ -1,116 +0,0 @@
{
"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": "回到地圖"
}
}
+9 -7
View File
@@ -1,15 +1,18 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' // import font awesome icon component
import { i18n } from './i18n' import './style.css'
import Toast, { POSITION } from 'vue-toastification' // import Vue Toastification /* import font awesome icon component */
import Vue3Lottie from 'vue3-lottie' // import Vue Lottie 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 'vue-toastification/dist/index.css' import 'vue-toastification/dist/index.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import formbricks from './utils/formbricks' import formbricks from './utils/formbricks'
import './style.css'
import './library'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
@@ -18,7 +21,6 @@ app.component('font-awesome-icon', FontAwesomeIcon)
app.use(pinia) app.use(pinia)
app.use(router) app.use(router)
app.use(i18n)
router.afterEach(() => { router.afterEach(() => {
if (typeof formbricks !== 'undefined') { if (typeof formbricks !== 'undefined') {
+51 -78
View File
@@ -1,100 +1,75 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { QuestLocale } from '@odyssey/constants' import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
import { FileGraph, type FileGraphUpdateEvent } from '@odyssey/file-graph' import { IQuest } from '@linux-odyssey/models'
import type { IResponse, ITask } from '@odyssey/game' import { createSession, getActiveSession, getQuest } from '../utils/api'
import type { SessionDetail } from '../../../server/src/routers/sessionRouter'
import Socket from '../utils/socket' import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal' import SocketTerminal from '../utils/terminal'
import type { Session } from '../types' import { Session, StageResponse } from '../types'
import { trpc } from '../utils/trpc'
import { i18n } from '../i18n'
const socket = new Socket() const socket = new Socket()
const term = new SocketTerminal() const term = new SocketTerminal(40, 80)
let hasSetup = false 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 { interface Store {
session: Session | null session: Session | null
questId: string questId: string
quest: QuestDetailResponse | null quest: IQuest | null
} }
export const useSession = defineStore('session', { const useSession = defineStore('session', {
state: (): Store => ({ state: (): Store => ({
session: null, session: null,
questId: '', questId: '',
quest: null, quest: null,
}), }),
actions: { actions: {
async setQuest(questId: string, locale: QuestLocale) { async setQuest(questId: string) {
this.quest = await trpc.quests.getQuestDetail.query({ this.quest = await getQuest(questId)
questId,
locale,
})
this.questId = questId this.questId = questId
}, },
async setSession(session: SessionDetail) { async setSession(session: Session) {
await this.setQuest(session.quest, session.locale) this.session = session
this.session.graph = new FileGraph(session.graph)
this.session = {
...session,
graph: new FileGraph(session.graph),
pwd: '/home/commander',
containerName: session.containerName || '',
}
term.reset() term.reset()
await socket.connect(this.session) await socket.connect(session)
await term.connect(
`/terminal/${session.containerName}/ws?token=${session.token}`
)
term.focus() term.focus()
}, },
async createSession() { async createSession() {
const locale = i18n.global.locale.value const session = await createSession(this.questId)
const session = await trpc.session.createSession.mutate({
questId: this.questId,
locale,
})
await this.setSession(session) await this.setSession(session)
term.send('echo start\n') socket.emit('terminal', 'echo start\n')
}, },
async getActiveSession(questId: string) { async getActiveSession() {
const session = await trpc.session.getActiveSession.query({ questId }) const session = await getActiveSession(this.questId)
if (session) { if (session) {
await this.setSession(session) this.setSession(session)
} }
}, },
newUpdate(update: SessionUpdate) { updateGraph(event: { discover?: FileObject[]; pwd?: string }) {
if (!this.session) return if (!this.session) return
this.session.responses = update.responses if (event.discover) {
this.session.tasks = update.tasks this.session.graph.discover(event.discover)
this.session.status = update.status }
// if ( if (event.pwd) {
// response.status === 'finished' && this.session.pwd = event.pwd
// this.session.status !== 'finished' }
// ) {
// this.finish()
// }
// this.session.status = response.status
}, },
finish() { newResponse(response: StageResponse) {
if (!this.session) return if (!this.session) return
this.session.status = 'finished' 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
}, },
finish() {},
reset() { reset() {
socket.reset() socket.reset()
term.reset() term.reset()
@@ -102,21 +77,17 @@ export const useSession = defineStore('session', {
}, },
setup() { setup() {
if (hasSetup) return if (hasSetup) return
// socket.on('terminal', (data: string) => { socket.on('terminal', (data: string) => {
// term.write(data) 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
}
}) })
socket.on('update', (update: SessionUpdate) => { term.onData((data: string) => {
this.newUpdate(update) socket.emit('terminal', data)
})
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
this.updateGraph(event)
})
socket.on('response', (response: StageResponse) => {
this.newResponse(response)
}) })
hasSetup = true hasSetup = true
}, },
@@ -130,3 +101,5 @@ export const useSession = defineStore('session', {
export function useTerminal() { export function useTerminal() {
return term return term
} }
export default useSession
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { trpc } from '../utils/trpc' import { getUserProfile } from '../utils/api'
interface IProgress { interface IProgress {
quest: string quest: string
@@ -23,7 +23,7 @@ const useUserProfile = defineStore('userProfile', {
}), }),
actions: { actions: {
async loadUserProfile() { async loadUserProfile() {
const res = await trpc.users.getMyProfile.query() const res = await getUserProfile()
this.$patch(res) this.$patch(res)
}, },
async resetUserProfile() { async resetUserProfile() {
+6 -6
View File
@@ -1,5 +1,6 @@
import type { FileGraph } from '@odyssey/file-graph' import { FileGraph } from '@linux-odyssey/file-graph'
import type { IResponse, ITask } from '@odyssey/game' // eslint-disable-next-line import/no-relative-packages
import { IResponse } from '@linux-odyssey/models'
export interface Task { export interface Task {
id: number id: number
@@ -19,8 +20,7 @@ export interface Session {
status: string status: string
graph: FileGraph graph: FileGraph
pwd: string pwd: string
tasks: ITask[] hints: string[][]
responses: IResponse[] tasks: Task[]
token: string responses: IResponse[][]
containerName: string
} }
-4
View File
@@ -1,4 +0,0 @@
declare module '*.css' {
const content: string
export default content
}
+40
View File
@@ -1,4 +1,8 @@
import axios from 'axios' 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({ const api = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
@@ -8,3 +12,39 @@ const api = axios.create({
withCredentials: true, withCredentials: true,
}) })
export default api 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
}
+3 -8
View File
@@ -1,5 +1,4 @@
import api from './api' import api from './api'
import { i18n } from '../i18n'
import { import {
TooManyRequestsError, TooManyRequestsError,
@@ -33,15 +32,11 @@ function handleRequestError(err: {
const { path, msg } = errors[0] const { path, msg } = errors[0]
switch (path) { switch (path) {
case 'username': case 'username':
throw new ValidationError( throw new ValidationError('Username is already taken')
i18n.global.t('authform_error.username_taken')
)
case 'email': case 'email':
throw new ValidationError(i18n.global.t('authform_error.email_taken')) throw new ValidationError('Email is already taken')
case 'password': case 'password':
throw new ValidationError( throw new ValidationError('Password is too weak')
i18n.global.t('authform_error.invalid_password')
)
default: default:
throw new ValidationError(msg) throw new ValidationError(msg)
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import formbricks from '@formbricks/js/website' import formbricks from '@formbricks/js/website'
import { useSession } from '../store/session' import useSession from '../store/session'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
+4 -2
View File
@@ -1,5 +1,5 @@
import { io, Socket } from 'socket.io-client' import { io, Socket } from 'socket.io-client'
import type { Session } from '../types' import { Session } from '../types'
type SocketCallback = (data: any) => void type SocketCallback = (data: any) => void
@@ -23,9 +23,11 @@ class SocketWrapper {
this.socket.on('connect', () => { this.socket.on('connect', () => {
console.log('Socket connected to session', session._id) console.log('Socket connected to session', session._id)
resolve()
}) })
this.socket.once('terminal', () => {
resolve()
})
this.socket.on('connect_error', reject) this.socket.on('connect_error', reject)
this.bindListeners() this.bindListeners()
}) })
+23 -59
View File
@@ -1,72 +1,47 @@
import { Terminal } from 'xterm' import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit' import { FitAddon } from 'xterm-addon-fit'
import ReconnectingWebSocket from 'reconnecting-websocket'
import 'xterm/css/xterm.css' import 'xterm/css/xterm.css'
class SocketTerminal { class SocketTerminal {
private term: Terminal private term: Terminal
private fitAddon: FitAddon private fitAddon: FitAddon
private socket: ReconnectingWebSocket | null = null
constructor() { constructor(rows: number, cols: number) {
this.term = new Terminal({ this.term = new Terminal({
convertEol: true, rows, // 行数
scrollback: 1000, cols, // 不指定行数,自动回车后光标从下一行开始
fontSize: 16, convertEol: true, // 启用时,光标将设置为下一行的开头
// scrollback: 50, //终端中的回滚量
disableStdin: false, // 是否应禁用输入
// cursorStyle: "underline", //光标样式
cursorBlink: true, // 光标闪烁
theme: { theme: {
foreground: '#ECECEC', foreground: '#ECECEC', // 字体
background: '#000000', background: '#000000', // 背景色
cursor: 'help', cursor: 'help', // 设置光标
}, },
fontSize: 18,
fontWeight: 'normal',
}) })
this.fitAddon = new FitAddon() this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon) this.term.loadAddon(this.fitAddon)
this.term.onData((data) => { this.fitAddon.fit()
if (this.socket) {
this.socket.send(data)
}
})
} }
connect(url: string): Promise<void> { write(message: string): void {
return new Promise((resolve, reject) => { this.term.write(message)
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()
}
this.socket.onclose = () => { onData(callback: any): void {
console.log('Disconnected from terminal') this.term.onData(callback)
} }
this.socket.onerror = (error) => { focus(): void {
console.error('WebSocket error:', error) this.term.focus()
}
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 { resizeScreen(): void {
try { this.fitAddon.fit()
this.fitAddon.fit()
} catch (e) {
console.error('Failed to resize terminal:', e)
}
} }
mount(reference: HTMLElement): void { mount(reference: HTMLElement): void {
@@ -74,19 +49,8 @@ class SocketTerminal {
this.resizeScreen() this.resizeScreen()
} }
send(message: string): void {
if (this.socket) {
this.socket.send(message)
}
}
focus(): void {
this.term.focus()
}
reset(): void { reset(): void {
this.term.reset() this.term.reset()
this.socket?.close()
} }
} }
-16
View File
@@ -1,16 +0,0 @@
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(),
// }
// },
}),
],
})
+7 -12
View File
@@ -1,14 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { isValidUsername } from '@linux-odyssey/utils'
import { isValidUsername } from '@odyssey/utils'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import Background from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import { TooManyRequestsError, ValidationError } from '../utils/errors' import { TooManyRequestsError, ValidationError } from '../utils/errors'
import { checkUsername, chooseUsername } from '../utils/auth' import { checkUsername, chooseUsername } from '../utils/auth'
const { t } = useI18n()
async function check({ async function check({
username, username,
error, error,
@@ -19,21 +15,21 @@ async function check({
}) { }) {
if (username) { if (username) {
if (!isValidUsername(username)) { if (!isValidUsername(username)) {
error(t('authform_error.invalid_username')) error('無效的帳號名稱')
return return
} }
try { try {
await checkUsername(username) await checkUsername(username)
} catch (err) { } catch (err) {
if (err instanceof TooManyRequestsError) { if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests')) error('太多請求,兩分鐘後再試一次。')
return return
} }
if (err instanceof ValidationError) { if (err instanceof ValidationError) {
error(err.message) error(err.message)
return return
} }
error(t('authform_error.auth_failed')) error('出了點問題,請再試一次。')
} }
} }
} }
@@ -53,7 +49,7 @@ async function handleSubmit({
success() success()
} catch (err: any) { } catch (err: any) {
if (err instanceof TooManyRequestsError) { if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests')) error('太多請求,兩分鐘後再試一次。')
return return
} }
if (err instanceof ValidationError) { if (err instanceof ValidationError) {
@@ -67,11 +63,10 @@ async function handleSubmit({
<template> <template>
<div class="w-screen h-screen"> <div class="w-screen h-screen">
<DynamicBackground class="w-full h-full" /> <Background class="w-full h-full" />
<div <div
class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<HeaderPart />
<div class="w-fit"> <div class="w-fit">
<AuthForm <AuthForm
@onSubmit="handleSubmit" @onSubmit="handleSubmit"
+3 -18
View File
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { useSession } from '../store/session' import useSession from '../store/session'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
import HeaderPart from '../components/header/HeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue' import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
@@ -15,8 +13,6 @@ import CompleteModal from '../components/game/CompleteModal.vue'
import StartButton from '../components/game/StartButton.vue' import StartButton from '../components/game/StartButton.vue'
import { openQuestSurvey } from '../utils/formbricks' import { openQuestSurvey } from '../utils/formbricks'
const { t, locale } = useI18n()
const toast = useToast()
const sessionStore = useSession() const sessionStore = useSession()
const userStore = useUserProfile() const userStore = useUserProfile()
@@ -31,13 +27,6 @@ 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({ const props = defineProps({
questId: { questId: {
type: String, type: String,
@@ -48,12 +37,8 @@ const props = defineProps({
onMounted(async () => { onMounted(async () => {
sessionStore.reset() sessionStore.reset()
sessionStore.setup() sessionStore.setup()
await sessionStore.setQuest(props.questId)
await sessionStore.getActiveSession(props.questId) await sessionStore.getActiveSession()
if (!sessionStore.session) {
await sessionStore.setQuest(props.questId, locale.value)
}
}) })
</script> </script>
+53 -43
View File
@@ -1,13 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import ContactButton from '../components/ContactButton.vue' import ContactButton from '../components/ContactButton.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import LandingHeaderComponents from '../components/header/LandingHeaderComponents.vue' import LandingHeaderComponents from '../components/header/LandingHeaderComponents.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
import { facebookUrl, githubUrl } from '../config' import { facebookUrl, githubUrl } from '../config'
const { t } = useI18n()
</script> </script>
<template> <template>
<div class="w-screen h-screen bg-scroll bg-bg"> <div class="w-screen h-screen bg-scroll bg-bg">
@@ -21,9 +18,13 @@ const { t } = useI18n()
<div <div
class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10" 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">Linux</h1> <h1 class="text-text-primary text-7xl font-extrabold font-mono">
Linux
</h1>
<img src="../img/icon_totem.svg" class="h-[30vh]" /> <img src="../img/icon_totem.svg" class="h-[30vh]" />
<h1 class="text-text-primary text-7xl font-extrabold">Odyssey</h1> <h1 class="text-text-primary text-7xl font-extrabold font-mono">
Odyssey
</h1>
</div> </div>
<!-- intro --> <!-- intro -->
<div class="w-full md:w-2/3 px-5"> <div class="w-full md:w-2/3 px-5">
@@ -35,7 +36,13 @@ const { t } = useI18n()
class="text-text inline-block bg-bg-primary p-5 rounded-xl opacity-90" class="text-text inline-block bg-bg-primary p-5 rounded-xl opacity-90"
style="font-size: 2.5vh" style="font-size: 2.5vh"
> >
{{ t('landing.intro') }} 程式設計不僅僅是寫程式碼,還涉及到如何有效地與你的電腦互動。
程式教育卻往往忽略背後「開發環境」的重要性,
使得許多學生在過程中充滿挫折。
「開發環境」是寫程式、執行程式過程所需要的一切工具。
而這些工具需要在終端機輸入指令來使用。
大部分的程式要用來運行的環境,並非 Windows 或 Mac OS,而是 Linux
作業系統。 Linux Odyssey 即是為此設計的線上 Linux 互動教學網站。
</h1> </h1>
</section> </section>
<section <section
@@ -50,27 +57,31 @@ const { t } = useI18n()
class="h-[18vh] row-span-2" class="h-[18vh] row-span-2"
/> />
<h1 <h1
class="text-text-primary p-5 font-extrabold col-span-2" class="text-text-primary p-5 font-extrabold font-mono col-span-2"
style="font-size: 3.5vh" style="font-size: 3.5vh"
> >
{{ t('landing.feature1') }} 初學者也能立即上手
</h1> </h1>
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh"> <h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
{{ t('landing.feature1_desc') }} 我們明白對於初學者來說,設定和使用開發工具可能是一大障礙。
學習者無需安裝任何軟體,僅需通過瀏覽器即可連上一個真實的 Linux
環境。
</h1> </h1>
</div> </div>
<div <div
class="bg-bg-primary p-5 rounded-xl opacity-90 grid grid-cols-3 grid-rows-2 justify-items-center items-center" class="bg-bg-primary p-5 rounded-xl opacity-90 grid grid-cols-3 grid-rows-2 justify-items-center items-center"
> >
<h1 <h1
class="text-text-primary p-5 font-extrabold col-span-2" class="text-text-primary p-5 font-extrabold font-mono col-span-2"
style="font-size: 3.5vh" style="font-size: 3.5vh"
> >
{{ t('landing.feature2') }} 遊戲般的學習體驗
</h1> </h1>
<img src="../img/icon_sword.svg" class="h-[18vh] row-span-2" /> <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"> <h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
{{ t('landing.feature2_desc') }} Linux Odyssey 融合了遊戲的樂趣與學習的實用性。
透過精心設計的遊戲關卡,學習者可以一邊接受挑戰、破解謎題,
一邊掌握 Linux 指令的運用。
</h1> </h1>
</div> </div>
<div <div
@@ -81,13 +92,15 @@ const { t } = useI18n()
class="h-[18vh] row-span-2" class="h-[18vh] row-span-2"
/> />
<h1 <h1
class="text-text-primary p-5 font-extrabold col-span-2 pt-8" class="text-text-primary p-5 font-extrabold font-mono col-span-2 pt-8"
style="font-size: 3.5vh" style="font-size: 3.5vh"
> >
{{ t('landing.feature3') }} 真實的 Linux 操作之旅
</h1> </h1>
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh"> <h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
{{ t('landing.feature3_desc') }} Linux Odyssey 提供的不僅是理論知識,更是真實的操作體驗。
每一個指令都是在真實的 Linux 環境中執行,
讓學習者能夠真正深入軟體開發的世界。
</h1> </h1>
</div> </div>
</section> </section>
@@ -107,7 +120,7 @@ const { t } = useI18n()
> >
<RouterLink title="StartGame" id="StartGame" to="/register"> <RouterLink title="StartGame" id="StartGame" to="/register">
<div class="text-text text-xl p-2 w-full text-center"> <div class="text-text text-xl p-2 w-full text-center">
{{ t('landing.start_journey') }} 準備好鍵盤,跟我們一起開啟你的 Linux 冒險吧!
</div> </div>
</RouterLink> </RouterLink>
</section> </section>
@@ -116,25 +129,24 @@ const { t } = useI18n()
class="text-text flex flex-col items-center mt-10 gap-10" class="text-text flex flex-col items-center mt-10 gap-10"
> >
<div class="w-full flex flex-col items-center gap-2"> <div class="w-full flex flex-col items-center gap-2">
<h1 class="text-text-primary text-3xl font-extrabold"> <h1 class="text-text-primary text-3xl font-extrabold font-mono">
{{ t('landing.team_intro') }} 團隊介紹
</h1> </h1>
<p class="text-lg"> <p class="text-lg">國立臺北科技大學 智慧自動化工程科 第二屆</p>
{{ t('landing.team_desc') }}
</p>
<div class="flex flex-row gap-5 justify-center"> <div class="flex flex-row gap-5 justify-center">
<h1 class="font-bold">{{ t('landing.team_member1') }}</h1> <h1 class="font-bold">Wancat</h1>
<h1 class="font-bold">{{ t('landing.team_member2') }}</h1> <h1 class="font-bold">Neil</h1>
<h1 class="font-bold">{{ t('landing.team_member3') }}</h1> <h1 class="font-bold">A.H.</h1>
<h1 class="font-bold">{{ t('landing.team_member4') }}</h1> <h1 class="font-bold">Ray Gur</h1>
</div> </div>
</div> </div>
<div class="w-full flex flex-col items-center gap-2"> <div class="w-full flex flex-col items-center gap-2">
<h1 class="text-text-primary text-3xl font-extrabold"> <h1 class="text-text-primary text-3xl font-extrabold font-mono">
{{ t('landing.awards') }} 得獎肯定
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-xl text-center">
{{ t('landing.award_desc') }} 大專校院資訊應用服務創新競賽<br />
鈦坦敏捷開發特別獎 第二名
</h1> </h1>
</div> </div>
<div class="container mx-auto"> <div class="container mx-auto">
@@ -142,37 +154,35 @@ const { t } = useI18n()
class="justify-items-center grid grid-cols-1 sm:grid-cols-3 gap-5" class="justify-items-center grid grid-cols-1 sm:grid-cols-3 gap-5"
> >
<h1 <h1
class="text-text-primary text-3xl font-extrabold sm:col-span-3 gap-6" class="text-text-primary text-3xl font-extrabold font-mono sm:col-span-3 gap-6"
> >
{{ t('landing.player_feedback') }} 玩家感想
<!--可以做成翻頁卡牌一次一個--> <!--可以做成翻頁卡牌一次一個-->
</h1> </h1>
<h1 class="text-xl text-center">"很好玩很像益智遊戲"</h1>
<h1 class="text-xl text-center"> <h1 class="text-xl text-center">
{{ t('landing.feedback1') }} "我從來不知道自由軟體的存在,謝謝你們。"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-xl text-center">
{{ t('landing.feedback2') }} "還蠻好玩的,讓程式變好玩"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-xl text-center">
{{ t('landing.feedback3') }} "以前不喜歡電腦課現在覺得喜歡一點點"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-xl text-center">
{{ t('landing.feedback4') }} "程式真的對新手很友善,讓我學到了很多東西"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-xl text-center">
{{ t('landing.feedback5') }} "感覺做得很好很簡明易懂關卡設計很用心"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-xl text-center">
{{ t('landing.feedback6') }} "這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-xl text-center">
{{ t('landing.feedback7') }} "以前我覺得寫程式很難不過現在覺得很有趣"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-xl text-center">
{{ t('landing.feedback8') }} "以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近"
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback9') }}
</h1> </h1>
</div> </div>
</div> </div>
@@ -182,8 +192,8 @@ const { t } = useI18n()
id="contact" id="contact"
class="text-text mt-10 flex flex-col items-center" class="text-text mt-10 flex flex-col items-center"
> >
<h1 class="text-text-primary font-extrabold text-3xl"> <h1 class="text-text-primary font-extrabold font-mono text-3xl">
{{ t('landing.contact_us') }} 聯絡我們
</h1> </h1>
<div <div
class="flex flex-row items-center justify-around mt-5 gap-5 mb-5" class="flex flex-row items-center justify-around mt-5 gap-5 mb-5"
+6 -20
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
@@ -7,8 +6,6 @@ import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue' import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
import api from '../utils/api' import api from '../utils/api'
const { t } = useI18n()
const leaderboard = ref< const leaderboard = ref<
{ {
username: string username: string
@@ -24,7 +21,7 @@ async function getLeaderboard() {
leaderboard.value = res.data leaderboard.value = res.data
} catch (err: any) { } catch (err: any) {
console.error(err) console.error(err)
toast.error(t('leaderboard.load_leaderboard_failed')) toast.error('讀取排行榜失敗')
} }
} }
@@ -40,33 +37,22 @@ onMounted(async () => {
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<div class="w-full"> <div class="w-full">
<HeaderPart <HeaderPart title="排行榜" :headerComponent="GameHeaderComponents" />
:title="t('leaderboard.title')"
:headerComponent="GameHeaderComponents"
/>
</div> </div>
<div class="w-fit h-[94vh] m-3"> <div class="w-fit h-[94vh] m-3">
<div <div
class="h-full bg-bg border-8 border-bg-primary flex flex-col items-center rounded-3xl p-10" 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 class="text-text-primary text-3xl mb-5">排行榜</h1>
{{ t('leaderboard.title') }}
</h1>
<div class="max-h-300 overflow-auto"> <div class="max-h-300 overflow-auto">
<table class="w-full text-sm text-left text-text"> <table class="w-full text-sm text-left text-text">
<thead <thead
class="text-xs text-text-secondary uppercase bg-bg-secondary" class="text-xs text-text-secondary uppercase bg-bg-secondary"
> >
<tr> <tr>
<th scope="col" class="px-6 py-3"> <th scope="col" class="px-6 py-3">使用者名稱</th>
{{ t('leaderboard.username') }} <th scope="col" class="px-6 py-3">分數</th>
</th> <th scope="col" class="px-6 py-3">完成關卡</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> </tr>
</thead> </thead>
<tbody> <tbody>
+4 -7
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
@@ -11,8 +10,6 @@ import {
} from '../utils/errors' } from '../utils/errors'
import { login } from '../utils/auth' import { login } from '../utils/auth'
const { t } = useI18n()
const handleLogin = async ({ const handleLogin = async ({
username, username,
password, password,
@@ -30,19 +27,19 @@ const handleLogin = async ({
if (isSuccess) success() if (isSuccess) success()
} catch (err) { } catch (err) {
if (err instanceof TooManyRequestsError) { if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests')) error('太多請求,兩分鐘後再試一次。')
return return
} }
if (err instanceof UnauthorizedError) { if (err instanceof UnauthorizedError) {
error(t('authform_error.unauthorized')) error('錯誤的帳號名稱或密碼。')
return return
} }
if (err instanceof ValidationError) { if (err instanceof ValidationError) {
error(err.message) error('無效的帳號名稱或密碼。')
return return
} }
console.error(err) console.error(err)
error(t('authform_error.auth_failed')) error('出了點問題。')
} }
} }
</script> </script>
+1 -4
View File
@@ -1,16 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import QuestMap from '../components/QuestMap.vue' import QuestMap from '../components/QuestMap.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue' import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
const { t } = useI18n()
</script> </script>
<template> <template>
<div class="w-fit"> <div class="w-fit">
<HeaderPart <HeaderPart
:title="t('quest_map.title')" title="關卡地圖"
:headerComponent="GameHeaderComponents" :headerComponent="GameHeaderComponents"
class="z-50" class="z-50"
/> />
+10 -13
View File
@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import validator from 'validator' import validator from 'validator'
import { isValidUsername } from '@odyssey/utils' import { isValidUsername } from '@linux-odyssey/utils'
import { passwordPolicy } from '@odyssey/constants' import { passwordPolicy } from '@linux-odyssey/constants'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
@@ -13,8 +12,6 @@ import {
ValidationError, ValidationError,
} from '../utils/errors' } from '../utils/errors'
const { t } = useI18n()
function handleRegister({ function handleRegister({
username, username,
email, email,
@@ -33,11 +30,11 @@ function handleRegister({
.then(success) .then(success)
.catch((err) => { .catch((err) => {
if (err instanceof TooManyRequestsError) { if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests')) error('太多請求,兩分鐘後再試一次。')
return return
} }
if (err instanceof UnauthorizedError) { if (err instanceof UnauthorizedError) {
error(t('authform_error.unauthorized')) error('無效的帳號名稱或密碼。')
return return
} }
if (err instanceof ValidationError) { if (err instanceof ValidationError) {
@@ -45,7 +42,7 @@ function handleRegister({
return return
} }
console.error(err) console.error(err)
error(t('authform_error.auth_failed')) error('出了點問題。')
}) })
} }
@@ -62,15 +59,15 @@ async function check({
error: (msg: string) => void error: (msg: string) => void
}) { }) {
if (username && !isValidUsername(username)) { if (username && !isValidUsername(username)) {
error(t('authform_error.invalid_username')) error('無效的帳號名稱')
return return
} }
if (email && !validator.isEmail(email)) { if (email && !validator.isEmail(email)) {
error(t('authform_error.invalid_email')) error('無效的電子郵件')
return return
} }
if (password && !validator.isStrongPassword(password, passwordPolicy)) { if (password && !validator.isStrongPassword(password, passwordPolicy)) {
error(t('authform_error.invalid_password')) error('密碼必須超過8個字元,至少包含一個數字、一個大寫及一個小寫。')
return return
} }
try { try {
@@ -81,11 +78,11 @@ async function check({
return return
} }
if (err instanceof TooManyRequestsError) { if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests')) error('太多請求,兩分鐘後再試一次。')
return return
} }
console.error(err) console.error(err)
error(t('authform_error.auth_failed')) error('出了點問題。')
} }
} }
</script> </script>
-14
View File
@@ -1,14 +0,0 @@
{
"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"]
}
+26 -3
View File
@@ -1,7 +1,30 @@
{ {
"files": [], "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"],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "../packages/utils" },
{ "path": "./tsconfig.node.json" } { "path": "../packages/constants" },
{ "path": "../packages/file-graph" },
{ "path": "../packages/models" }
] ]
} }
-24
View File
@@ -1,24 +0,0 @@
{
"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"]
}
-6
View File
@@ -5,13 +5,7 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { server: {
host: '0.0.0.0',
proxy: { proxy: {
'/trpc': {
target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false,
xfwd: true,
},
'/api': { '/api': {
target: process.env.API_TARGET || 'http://localhost:3000', target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false, changeOrigin: false,
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
}
-2167
View File
File diff suppressed because it is too large. Load diff
-3
View File
@@ -1,3 +0,0 @@
[test]
coverage = true
coverageDir = "coverage"
+18 -1
View File
@@ -1,10 +1,27 @@
server { server {
listen 80; listen 80;
error_log /var/log/nginx/error.log debug;
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
try_files $uri $uri/ /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;
}
} }
+11 -47
View File
@@ -1,23 +1,6 @@
services: 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: app:
image: linuxodyssey/frontend:${IMAGE_TAG:-latest} image: linuxodyssey/frontend
depends_on: depends_on:
- backend - backend
restart: always restart: always
@@ -26,16 +9,12 @@ services:
dockerfile: app/Dockerfile dockerfile: app/Dockerfile
target: prod target: prod
networks: networks:
- internal internal:
labels: ports:
- 'traefik.enable=true' - 8080:80
- '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: backend:
image: linuxodyssey/backend:${IMAGE_TAG:-latest} image: linuxodyssey/backend
depends_on: depends_on:
- db - db
restart: always restart: always
@@ -52,32 +31,17 @@ services:
- ./config:/app/config - ./config:/app/config
- ./quests:/app/quests:ro - ./quests:/app/quests:ro
env_file: env_file:
- .env.prod - .env
environment: environment:
- DOCKER_NETWORK=linux-odyssey-players - DOCKER_NETWORK=linux-odyssey-players
- BACKEND_URL=http://backend:3000 - BACKEND_URL=http://backend:3000
- HOST_PROJECT_ROOT=${PWD} - 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: analytics:
image: linuxodyssey/analytics:${IMAGE_TAG:-latest} image: linuxodyssey/analytics
build: build:
context: . context: .
dockerfile: services/analytics/Dockerfile dockerfile: packages/analytics/Dockerfile
target: prod target: prod
restart: always restart: always
depends_on: depends_on:
@@ -87,7 +51,7 @@ services:
ports: ports:
- '3001:3001' - '3001:3001'
env_file: env_file:
- .env.prod - .env
db: db:
image: mongo:6 image: mongo:6
@@ -100,10 +64,10 @@ services:
- mongo:/data/db - mongo:/data/db
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
env_file: env_file:
- .env.prod - .env
base: base:
image: linuxodyssey/quest-base:${IMAGE_TAG:-latest} image: linuxodyssey/quest-base
build: build:
context: . context: .
dockerfile: ./quests/Dockerfile dockerfile: ./quests/Dockerfile
-27
View File
@@ -1,25 +1,5 @@
version: '3' version: '3'
services: 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: db:
image: mongo:6 image: mongo:6
restart: always restart: always
@@ -39,13 +19,6 @@ services:
dockerfile: ./quests/Dockerfile dockerfile: ./quests/Dockerfile
extra_hosts: extra_hosts:
- 'host.docker.internal:host-gateway' - '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: networks:
internal: internal:
+92 -143
View File
@@ -1,192 +1,141 @@
# Windows Environment Setup # Windows Environment Setup
## 1. Install WSL and Ubuntu ## Install WSL and Ubuntu
Refer to Microsoft’s official [WSL installation guide](https://learn.microsoft.com/en-us/windows/wsl/install). Please refer to the Official [Setup WSL](https://learn.microsoft.com/en-us/windows/wsl/setup/environment) on Microsoft's website.
Open PowerShell as Administrator and run: Run the following command in PowerShell as Administrator:
```powershell wsl --install
wsl --install
```
Restart your computer after the installation finishes. Then restart the computer. After restarting, you can open Ubuntu terminal by searching "Ubuntu" in Start menu. You will be prompted to create a new user account. Once you have done that, you can continue with the following steps.
Open Ubuntu from the Start menu. The first time Ubuntu starts, it will ask you to create a Linux username and password. ## Setup Environment (In Ubuntu terminal)
Verify that Ubuntu is using WSL 2 by running this command in PowerShell: 1. Update and upgrade
```powershell ```
wsl --update sudo apt-get update
wsl --list --verbose sudo apt-get upgrade
``` ```
Ubuntu should show `2` in the `VERSION` column. 1. Install Git
If it is using WSL 1, run: ```
sudo apt-get install git
```
```powershell 1. Install Node.js (Version 20.x)
wsl --set-version Ubuntu 2
```
## 2. Install Docker Desktop ```
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install nodejs
sudo apt install build-essential
sudo npm install -g yarn
```
Install [Docker Desktop for Windows](https://docs.docker.com/desktop/setup/install/windows-install/). ## Install Docker
Start Docker Desktop after installation. (Refer to [get.docker.com](https://get.docker.com/))
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. To install the latest stable versions of Docker CLI, Docker Engine, and their
dependencies:
Go to: 1. Download the script
```text ```
Settings > Resources > WSL Integration curl -fsSL https://get.docker.com -o install-docker.sh
``` ```
Enable integration with your Ubuntu distribution, then select **Apply & restart**. 1. verify the script's content
If the WSL Integration section is unavailable, ensure Docker Desktop is using Linux containers. ```
cat install-docker.sh
```
> [!IMPORTANT] 1. run the script with --dry-run to verify the steps it executes
> 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: ```
sh install-docker.sh --dry-run
```
```bash **NOTE: the script will recommend you to use Docker Desktop instead, but we need to use Docker inside WSL for the backend to work.** Just ignore it and wait for the installation to finish.
docker --version
docker compose version
docker run --rm hello-world
```
## 3. Set Up the Ubuntu Environment 1. run the script either as root, or using sudo to perform the installation.
Run the following commands in the Ubuntu terminal. ```
sudo sh install-docker.sh
```
### Update the Package List 1. Verify the installation
```bash ```
sudo apt update docker --version
``` ```
### Install the Required Tools 1. Add your user to the docker group
```bash ```
sudo apt install -y git curl ca-certificates build-essential sudo usermod -aG docker $USER
``` ```
### Install Bun **NOTE: You need to create a new WSL terminal to apply the changes.**
```bash 1. Verify the installation
curl -fsSL https://bun.sh/install | bash
```
Restart your terminal (or run `source ~/.bashrc`) so `bun` is on your `PATH`. ```
docker ps
```
This should return something like:
Verify the installations: CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
```bash ## Run the Project (In Ubuntu terminal)
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. 1. Clone the repository
## 4. Clone the Project ```
git clone https://github.com/lancatlin/linux-odyssey.git
cd linux-odyssey
```
> [!WARNING] Optional: To open the project in VSCode, run the following command inside the project folder:
> 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: ```
code .
```
```bash 1. Install dependencies
mkdir -p <path-to-projects-directory>
cd <path-to-projects-directory>
```
Clone the repository: ```
yarn install
```
It is ok if there are some warnings and `gyp: ERR` errors, just verify there is `done` at the end.
```bash 1. Copy the environment variables
git clone https://github.com/linux-odyssey/linux-odyssey.git
cd linux-odyssey
```
### Optional: Open the Project in VS Code ```
cp .env.sample .env
```
Install [Visual Studio Code](https://code.visualstudio.com/) and the **WSL** extension first. Then run: 1. Docker setup
```bash ```
code . docker compose pull
``` docker compose build
docker compose up -d db
```
## 5. Install Project Dependencies <!-- Check folder ./config exist and is empty -->
<!-- Check .env file exist -->
From the project root, run: 1. Run the project
```bash ```
bun install yarn build
``` yarn dev
```
Warnings during installation may be harmless. The installation succeeded if Bun finishes with an `installed` message and does not display an `error:` message. <!-- Check ./config/ssh_key.pub is a file not a folder -->
If a `gyp` error appears, confirm that it belongs to an optional dependency before ignoring it. Enjoy!
## 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
View File
@@ -0,0 +1,201 @@
/**
* 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
+32 -27
View File
@@ -9,48 +9,53 @@
"app", "app",
"server", "server",
"packages/*", "packages/*",
"services/*",
"tests" "tests"
], ],
"scripts": { "scripts": {
"app": "bun run --filter app dev", "app": "yarn workspace app dev",
"build": "bun run --filter app build", "build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"bun run --filter app dev\" \"bun run --filter server dev\"", "dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
"server": "bun run --filter server dev", "server": "yarn workspace server dev",
"cli": "yarn workspace client start",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --ignore-path .prettierignore --write app server", "format": "prettier --ignore-path .prettierignore --write app server",
"test": "bun test", "test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
"test:e2e": "bun run --filter tests test", "test:e2e": "yarn workspace tests test",
"test:open": "bun run --filter tests test:open", "test:open": "yarn workspace tests test:open",
"analytics": "bun run --filter analytics dev", "analytics": "yarn workspace analytics dev",
"setup": "bun run --filter server setup", "setup": "yarn workspace server setup",
"clean": "bun run --filter server clean", "clean": "yarn workspace server clean",
"build:quests": "bun run --filter server build:quests", "build:quests": "yarn workspace server build:quests"
"type-check": "bun run --filter server --filter analytics --filter app type-check"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.4.2", "@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/node": "^20.10.4", "@types/node": "^20.10.4",
"@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/parser": "^6.14.0",
"@vue/eslint-config-prettier": "^7.1.0", "@vue/eslint-config-prettier": "^7.1.0",
"babel-jest": "^29.7.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^8.56.0", "eslint": "^8.44.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-cypress": "^2.15.1", "eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-import": "^2.29.0", "eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue": "^9.15.1",
"globals": "^16.0.0", "jest": "^29.7.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"typescript": "^5.3.3", "ts-node": "^10.9.2",
"vue-eslint-parser": "^9.4.2" "typescript": "^5.3.3"
}, },
"packageManager": "bun@1.3.14", "dependencies": {
"overrides": { "latest": "^0.2.0",
"@mapbox/node-pre-gyp/tar": "^7.5.19", "typescript-eslint": "^7.4.0"
"@cypress/request/form-data": "^2.5.4" },
} "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }
File renamed without changes.
+19
View File
@@ -0,0 +1,19 @@
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" ]
File renamed without changes.
+6
View File
@@ -0,0 +1,6 @@
{
"watch": ["src", "views"],
"ext": "ts,handlebars,js",
"ignore": ["src/**/*.spec.ts"],
"exec": "node --loader ts-node/esm src/index.ts"
}
+32
View File
@@ -0,0 +1,32 @@
{
"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"
}
}
File renamed without changes.

Before

Width:  |  Height:  |  Size: 630 KiB

After

Width:  |  Height:  |  Size: 630 KiB

File renamed without changes.
@@ -1,6 +1,6 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import Pagination from '../models/pagination' import Pagination from '../models/pagination.js'
import { errorCommands } from '../models/commands' import { errorCommands } from '../models/commands.js'
export async function commandListController( export async function commandListController(
req: Request, req: Request,
@@ -1,6 +1,6 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { userCount } from '../models/users' import { userCount } from '../models/users.js'
import { questList } from '../models/quests' import { questList } from '../models/quests.js'
export async function homeController(req: Request, res: Response) { export async function homeController(req: Request, res: Response) {
const quests = await questList() const quests = await questList()
@@ -1,5 +1,5 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { questList } from '../models/quests' import { questList } from '../models/quests.js'
export async function questListController(req: Request, res: Response) { export async function questListController(req: Request, res: Response) {
const quests = await questList() const quests = await questList()
@@ -1,6 +1,6 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import Pagination from '../models/pagination' import Pagination from '../models/pagination.js'
import { sessionList, sessionDetail } from '../models/sessions' import { sessionList, sessionDetail } from '../models/sessions.js'
export async function sessionListController(req: Request, res: Response) { export async function sessionListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string } const { nextKey, order } = req.query as { nextKey: string; order: string }
@@ -1,7 +1,7 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import mongoose from 'mongoose' import mongoose from 'mongoose'
import Pagination from '../models/pagination' import Pagination from '../models/pagination.js'
import { userList, userDetail, idToUser } from '../models/users' import { userList, userDetail, idToUser } from '../models/users.js'
export async function userListController(req: Request, res: Response) { export async function userListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string } const { nextKey, order } = req.query as { nextKey: string; order: string }
@@ -1,9 +1,9 @@
import connectDB from '@linux-odyssey/models'
import express from 'express' import express from 'express'
import { engine } from 'express-handlebars' import { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth' import basicAuth from 'express-basic-auth'
import { connectDB } from '@odyssey/models' import config from './config.js'
import config from './config' import router from './router.js'
import router from './router'
async function main() { async function main() {
await connectDB(config.db) await connectDB(config.db)
@@ -1,7 +1,6 @@
import { Types } from 'mongoose' import { Types } from 'mongoose'
export interface User { export interface User {
_id: string
username: string username: string
} }
export interface SessionDocument { export interface SessionDocument {
@@ -9,10 +8,8 @@ export interface SessionDocument {
user: User user: User
quest: string quest: string
status: string status: string
locale: string
createdAt: Date createdAt: Date
finishedAt: Date finishedAt: Date
lastActivityAt: Date
} }
export interface CommandObject { export interface CommandObject {
command: string command: string
@@ -24,17 +21,24 @@ export interface CommandObject {
} }
export interface SessionObject { export interface SessionObject {
_id: Types.ObjectId _id: Types.ObjectId
user: User user: string
quest: string quest: string
locale: string
status: string status: string
createdAt: string createdAt: string
finishedAt: string finishedAt: string
lastActivityAt: string lastActivityAt: string
usedTime: string usedTime: string
commandCount: number commandCount: number
commands: CommandObject[]
} }
export interface SessionDetail extends SessionObject { export interface SessionDetail {
_id: Types.ObjectId
user: string
quest: string
status: string
createdAt: string
finishedAt: string
usedTime: string
commands: CommandObject[] commands: CommandObject[]
} }
export interface IUser extends Document { export interface IUser extends Document {
@@ -1,5 +1,5 @@
import { Command } from '@odyssey/models' import { Command } from '@linux-odyssey/models'
import Pagination from './pagination' import Pagination from './pagination.js'
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export async function errorCommands(pagination: Pagination) { export async function errorCommands(pagination: Pagination) {
@@ -1,5 +1,5 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
/* eslint-disable max-classes-per-file */ // eslint-disable-next-line max-classes-per-file
import mongoose, { Types } from 'mongoose' import mongoose, { Types } from 'mongoose'
export const DESC: string = 'desc' export const DESC: string = 'desc'
@@ -1,14 +1,8 @@
import { UserProfile } from '@odyssey/models' /* eslint-disable object-shorthand */
/* eslint-disable func-names */
import { UserProfile } from '@linux-odyssey/models'
export interface QuestProgress { export function questList() {
_id: string
started: number
completed: number
completedRate: number
failed: number
}
export function questList(): Promise<QuestProgress[]> {
return UserProfile.aggregate([ return UserProfile.aggregate([
{ {
$project: { $project: {
@@ -1,5 +1,5 @@
import { Command, Session } from '@odyssey/models' import { Command, Session } from '@linux-odyssey/models'
import Pagination from './pagination' import Pagination from './pagination.js'
import { import {
CommandObject, CommandObject,
SessionDetail, SessionDetail,
@@ -28,6 +28,14 @@ export async function sessionList(
}, },
}, },
{ $unwind: '$user' }, { $unwind: '$user' },
{
$lookup: {
from: 'commands',
localField: '_id',
foreignField: 'session',
as: 'commands',
},
},
pagination.sort('_id'), pagination.sort('_id'),
pagination.limit(), pagination.limit(),
]) ])
@@ -37,17 +45,16 @@ export async function sessionList(
_id, _id,
user, user,
quest, quest,
locale,
status, status,
createdAt, createdAt,
finishedAt, finishedAt,
commands,
lastActivityAt, lastActivityAt,
} = session } = session
return { return {
_id, _id,
user: { _id: user._id, username: user.username }, user: user.username, // Assuming `user` is always populated.
quest, quest,
locale: locale ?? 'zh-TW',
status, status,
createdAt: createdAt?.toLocaleString(), createdAt: createdAt?.toLocaleString(),
finishedAt: finishedAt?.toLocaleString(), finishedAt: finishedAt?.toLocaleString(),
@@ -56,7 +63,8 @@ export async function sessionList(
lastActivityAt && createdAt lastActivityAt && createdAt
? formatTime(lastActivityAt.getTime() - createdAt.getTime()) ? formatTime(lastActivityAt.getTime() - createdAt.getTime())
: 'N/A', // Ensure both dates are present : 'N/A', // Ensure both dates are present
commandCount: 0, commandCount: commands?.length || 0,
commands,
} }
}) })
} }
@@ -75,26 +83,14 @@ export async function sessionDetail(id: string): Promise<SessionDetail> {
createdAt: createdAt?.toLocaleString(), createdAt: createdAt?.toLocaleString(),
}) })
) )
const { const { _id, user, quest, status, createdAt, finishedAt } = session
_id,
user,
quest,
locale,
status,
createdAt,
finishedAt,
lastActivityAt,
} = session
return { return {
_id, _id,
user: { _id: user._id, username: user.username }, user: user.username,
quest, quest,
locale: locale ?? 'zh-TW',
status, status,
createdAt: createdAt?.toLocaleString(), createdAt: createdAt?.toLocaleString(),
finishedAt: finishedAt?.toLocaleString(), finishedAt: finishedAt?.toLocaleString(),
lastActivityAt: lastActivityAt?.toLocaleDateString(),
commandCount: commands.length,
usedTime: finishedAt usedTime: finishedAt
? formatTime(finishedAt.getTime() - createdAt.getTime()) ? formatTime(finishedAt.getTime() - createdAt.getTime())
: '', : '',
@@ -1,7 +1,7 @@
import { HydratedDocument, Types } from 'mongoose' import { Session, User } from '@linux-odyssey/models'
import { Session, User, IUser, ISession } from '@odyssey/models' import { Types } from 'mongoose'
import Pagination from './pagination' import Pagination from './pagination.js'
import { UserSessionDetail } from '../interface' import { IUser, UserSessionDetail } from '../interface.js'
function loginMethods(user: IUser): string[] { function loginMethods(user: IUser): string[] {
const methods: string[] = [] const methods: string[] = []
@@ -23,11 +23,7 @@ export function userCount() {
} }
export async function userList(pagination: Pagination) { export async function userList(pagination: Pagination) {
const users: { const users = await Session.aggregate([
userData: HydratedDocument<IUser>
count: number
lastActivityAt: Date
}[] = await Session.aggregate([
pagination.match('user'), pagination.match('user'),
{ {
$group: { $group: {
@@ -66,7 +62,7 @@ export async function userList(pagination: Pagination) {
export async function userDetail( export async function userDetail(
id: Types.ObjectId id: Types.ObjectId
): Promise<UserSessionDetail[]> { ): Promise<UserSessionDetail[]> {
const sessions: HydratedDocument<ISession>[] = await Session.find({ const sessions = await Session.find({
user: id, user: id,
}) })
return sessions.map((session): UserSessionDetail => { return sessions.map((session): UserSessionDetail => {
@@ -1,13 +1,16 @@
import { Router } from 'express' import { Router } from 'express'
import { homeController } from './controllers/index' import { homeController } from './controllers/index.js'
import { userListController, userDetailController } from './controllers/users' import {
import { questListController } from './controllers/quests' userListController,
import { commandListController } from './controllers/commands' userDetailController,
} from './controllers/users.js'
import { questListController } from './controllers/quests.js'
import { commandListController } from './controllers/commands.js'
import { import {
sessionDetailController, sessionDetailController,
sessionListController, sessionListController,
} from './controllers/sessions' } from './controllers/sessions.js'
const router = Router() const router = Router()
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'bun:test'
import mongoose from 'mongoose' import mongoose from 'mongoose'
import Pagination, { ASC, DESC } from '../src/models/pagination.js' import Pagination, { ASC, DESC } from '../src/models/pagination.js'
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "./dist"
},
"include": ["src"],
"references": [{ "path": "../models" }],
"ts-node": {
"swc": true
}
}
@@ -6,13 +6,9 @@
<th>Quest</th> <th>Quest</th>
<td>{{quest}}</td> <td>{{quest}}</td>
</tr> </tr>
<tr>
<th>Locale</th>
<td>{{locale}}</td>
</tr>
<tr> <tr>
<th>User</th> <th>User</th>
<td><a href="/users/{{user._id}}">{{user.username}}</a></td> <td>{{user}}</td>
</tr> </tr>
<tr> <tr>
<th>Status</th> <th>Status</th>
@@ -59,4 +55,4 @@
{{/each}} {{/each}}
</table> </table>
</div>{{/with}} </div>{{/with}}
@@ -12,22 +12,22 @@
<tr> <tr>
<th>User</th> <th>User</th>
<th>Quest</th> <th>Quest</th>
<th>Locale</th>
<th>Status</th> <th>Status</th>
<th>Created At</th> <th>Created At</th>
<th>Finished At</th> <th>Finished At</th>
<th>Used Time</th> <th>Used Time</th>
<th>Command Count</th>
<th>Session</th> <th>Session</th>
</tr> </tr>
{{#each sessions}} {{#each sessions}}
<tr> <tr>
<td><a href="/users/{{user._id}}">{{user.username}}</a></td> <td>{{user}}</td>
<td>{{quest}}</td> <td>{{quest}}</td>
<td>{{locale}}</td>
<td>{{status}}</td> <td>{{status}}</td>
<td>{{createdAt}}</td> <td>{{createdAt}}</td>
<td>{{finishedAt}}</td> <td>{{finishedAt}}</td>
<td>{{usedTime}}</td> <td>{{usedTime}}</td>
<td>{{commandCount}}</td>
<td><a href='/sessions/{{_id}}'>Detail</a></td> <td><a href='/sessions/{{_id}}'>Detail</a></td>
</tr> </tr>
{{/each}} {{/each}}
@@ -37,4 +37,4 @@
<a href='/sessions?nextKey={{nextKey}}&order={{order}}'><div class='btn'> <a href='/sessions?nextKey={{nextKey}}&order={{order}}'><div class='btn'>
<h3>Next</h3> <h3>Next</h3>
</div></a> </div></a>
</div> </div>
Loaded 100 of 413 files, more files were not shown because too many files have changed in this diff. Show more