Compare commits

..
Author SHA1 Message Date
Chen, Tai-Lin aebe587ad3 update 2024-04-30 01:40:42 +08:00
Chen, Tai-Lin c96ee83fa3 update quest\permission 2024-04-08 22:39:23 +08:00
420 changed files with 7453 additions and 13567 deletions

No files matched your search

+1 -2
View File
@@ -1,5 +1,4 @@
**/node_modules
.git
**/dist
**/tsconfig.tsbuildinfo
data
**/tsconfig.tsbuildinfo
-50
View File
@@ -1,50 +0,0 @@
HOST=localhost
PORT=3000
BASE_URL=https://example.com
SECRET_KEY=YOU_SHOULD_CHANGE_THIS
BACKEND_URL=http://backend:3000
DOCKER_NETWORK=linux-odyssey-players
MONGO_ROOT_PASSWORD=adminpassword
MONGO_USER=commander
MONGO_PASS=commander
MONGO_DB=linux-odyssey
MONGO_URL=mongodb://commander:commander@db:27017/linux-odyssey
# Admin user credentials for analytics
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
ADMIN_HOST=0.0.0.0
ADMIN_PORT=3001
# ADMIN_BASE_URL=http://0.0.0.0:3001
# Optional
# CONTAINER_EXPIRY=60 # 60 minutes
# SESSION_MAX_AGE=168 # 7 days
# TRUSTED_PROXIES=1.2.3.4
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
# RATE_LIMIT_LOCKOUT_TIME=10
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# SURVEY_URL=
# BUG_REPORT_URL=
# Docker related config
# DOCKER_NETWORK=host
# QUEST_IMAGE=linuxodyssey/quest-base
# DOCKER_PREFIX=linuxodyssey/quest-
# To mount quest folder from host into container, for development purpose
# MOUNT_QUEST=true
# LOG_PATH=
# Testing, create a user with the following credentials, disable rate limiting
# TESTING=true
# TESTING_USERNAME=alex
# TESTING_PASSWORD=Alex1234
-38
View File
@@ -1,38 +0,0 @@
HOST=localhost
PORT=3000
BASE_URL=http://localhost:8000
MONGO_URL=mongodb://localhost:27017/odyssey-test
SECRET_KEY=sample-secret
DOCKER_NETWORK=linux-odyssey-players-dev
# Optional
# BACKEND_URL=http://host.docker.internal:3000
# CONTAINER_EXPIRY=60 # 60 minutes
# SESSION_MAX_AGE=168 # 7 days
# TRUSTED_PROXIES=1.2.3.4
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
# RATE_LIMIT_LOCKOUT_TIME=10
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# SURVEY_URL=
# BUG_REPORT_URL=
# Docker related config
# DOCKER_NETWORK=host
# QUEST_IMAGE=linuxodyssey/quest-base
# DOCKER_PREFIX=linuxodyssey/quest-
# To mount quest folder from host into container, for development purpose
# MOUNT_QUEST=true
# LOG_PATH=
# Testing, create a user with the following credentials, disable rate limiting
# TESTING=true
# TESTING_USERNAME=alex
# TESTING_PASSWORD=Alex1234
+7 -33
View File
@@ -20,12 +20,9 @@ module.exports = {
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.ts'],
moduleDirectory: ['node_modules', 'packages', 'server/src'],
},
typescript: {
alwaysTryTypes: true,
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
typescript: {},
},
vue: {
version: '3.0', // Explicitly specify Vue 3
@@ -52,7 +49,7 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
'error',
{
args: 'after-used',
argsIgnorePattern: '^_',
@@ -66,44 +63,21 @@ module.exports = {
// Allow missing file extensions
'import/extensions': [
'off',
'warn',
'ignorePackages',
{
js: 'never',
js: 'off',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
'no-unused-vars': 'warn',
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': 'error',
'no-empty-function': 'off',
'@typescript-eslint/no-empty-function': 'error',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-restricted-imports': ['error', '../*'],
'import/no-relative-packages': 'off',
'no-await-in-loop': 'off',
},
overrides: [
{
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'],
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
rules: {
'import/extensions': 'off',
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['server/**/*.ts'],
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['**/packages/**/*.ts'],
rules: {
'no-restricted-imports': 'off',
'import/extensions': 'off', // Turn off the rule for test files
'import/no-extraneous-dependencies': 'off',
},
},
+3 -4
View File
@@ -18,9 +18,9 @@ on:
secrets:
DOCKERHUB_USERNAME:
required: false
required: true
DOCKERHUB_TOKEN:
required: false
required: true
jobs:
build-and-push:
@@ -42,7 +42,6 @@ jobs:
type=semver,pattern={{version}}
- name: Login to Docker Hub
if: always()
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -54,5 +53,5 @@ jobs:
context: .
file: ${{ inputs.dockerfilePath }}
target: ${{ inputs.target }}
push: ${{ secrets.DOCKERHUB_USERNAME != '' }}
push: true
tags: ${{ steps.meta.outputs.tags }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: ./.github/workflows/build-docker-image.yml
with:
imageName: 'linuxodyssey/analytics'
dockerfilePath: './services/analytics/Dockerfile'
dockerfilePath: './packages/analytics/Dockerfile'
target: prod
secrets: inherit
+29
View File
@@ -2,6 +2,15 @@ name: CI Checks
on:
workflow_call:
secrets:
GOOGLE_CLIENT_ID:
required: true
GOOGLE_CLIENT_SECRET:
required: true
GH_CLIENT_ID:
required: true
GH_CLIENT_SECRET:
required: true
jobs:
ci:
@@ -26,3 +35,23 @@ jobs:
- name: Test
run: yarn test
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Build Test Environment, Stage 1
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
- name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
- name: Run Cypress Tests
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
run: docker-compose -f docker-compose.testing.yml run cypress
-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}}"
-25
View File
@@ -1,25 +0,0 @@
name: E2E Tests
on:
workflow_dispatch:
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Build Test Environment, Stage 1
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
- name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
- name: Run Cypress Tests
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
run: docker-compose -f docker-compose.testing.yml run cypress
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build:
needs: ci-checks
+1
View File
@@ -10,6 +10,7 @@ concurrency:
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build:
uses: ./.github/workflows/build.yml
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build:
needs: ci-checks
+1 -9
View File
@@ -1,9 +1,3 @@
config
.quest
tmp
services/terminal-service/terminal-service
bin
# Logs
logs
*.log
@@ -91,7 +85,6 @@ built
.env.test.local
.env.production.local
.env.local
.env.prod
# parcel-bundler cache (https://parceljs.org/)
.cache
@@ -144,5 +137,4 @@ dist
.pnp.*
#screenshots
tests/cypress/screenshots
services/cmd-hook/cmd-hook
tests/cypress/screenshots
+1 -7
View File
@@ -1,11 +1,5 @@
{
"editor.tabSize": 2,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"yaml.schemas": {
"packages/game/quest.json": "quests/**/game.yml"
},
"[go]": {
"editor.defaultFormatter": "golang.go"
}
"editor.formatOnSave": true
}
+73 -120
View File
@@ -3,24 +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)
[![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
yarn install
cp .env.sample .env
docker compose up -d
yarn dev
## Requirements
- NodeJS v20+
- NodeJS
- Docker
- Yarn: v1.22
## Known Issues
- MongoDB requires x86 architecture to run for v5+ versions. You can track [this issue](https://github.com/linux-odyssey/linux-odyssey/issues/166) for updates.
Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for setup instructions.
- Yarn
## Installation
@@ -28,61 +15,30 @@ Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for
cd linux-odyssey
yarn install
## Project Structures
## Development
1. **app**: frontend
2. **server**: backend
3. **quests**: tutorials scripts for each quest
4. **packages**: other packages
1. **analytics**: admin dashboard to analyze collected data
2. **constants**: some shared config values for app and server
3. **container**: the script run in container to capture users' command
4. **file-graph**: the algorithm to construct and modify file graph, used in both app and server
5. **models**: definitions of MondoDB collections (data schema)
6. **utils**: other utility code shared between packages
7. **game (in the future)**: core logic of game playing, will be extracted from server
Run mongoDB first:
## Configuration
docker compose -f docker-compose.dev.yml up -d db
The configuration is stored in the `.env` file. You can copy from the sample file.
The db will automatically restarts by Docker.
cp .env.sample .env
Build up dependencies:
## Docker Setup
yarn build:deps
### Docker compose files explaination
Run dev server (frontend, backend):
1. **docker-compose.yml**: The one used for development. It sets up MongoDB and builds base quest image.
2. **docker-compose.prod.yml**: The one used for production deployment. It has a Nginx service, a backend, a database, an analytics service, and creates isolated networks for player containers.
3. **docker-compose.testing.yml**: Similar to production config, but sets up an one-time environment for end-to-end testing.
yarn dev
### Common Development Setup
If you want to enable social login, you should have OAuth client token in a `.env` file:
This sets up a development database, and running development server for frontend and backend.
### Build Quest Images
Pull the latest images:
docker compose pull
Start the containers:
docker compose up -d
This will start the db. it will automatically restarts by Docker.
Building quest images:
docker compose build base
### Build Quest Images in Details
In each quest folder, there is a `game.yml` file, which is the quest definition.
If you want to include additional files, put them in a `home` folder. The files in `home` will be copied to the user's home on container creation automatically.
In each quest folder, there is a `game.yml` file, which is the quest definition. And a optional `Dockerfile` to overwrite the base image. If you want to include additional files, put them in a `home` folder. The files in `home` will **not** be copied to the image automatically, you have to copy them in the `Dockerfile`.
quests/
├── Dockerfile # Base image
├── entrypoint.sh # Entrypoint for the container
├── discover
│ └── game.yml
├── helloworld
@@ -98,56 +54,14 @@ If you want to include additional files, put them in a `home` folder. The files
└── home
└── ancient_scroll.txt
An optional `Dockerfile` can overwrite the base image. You can use this to install additional dependencies or modify the environment.
Customized `entrypoint.sh` for each quest is not yet supported.
To build all quests:
yarn build:quests
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
### Optional: Mounting Host Directory
To update the home content of a quest, simply re-create a new session will do. The home content is mounted and copied on container creation.
To further improve the development experience, we can mount the host directory to the container in the pattern:
/path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables:
MOUNT_QUEST=true
### Optional: Mounting terminal service & cmd-hook
To make the development of terminal services & cmd-hook smoother, we can enable `MOUNT_CLI` to mount the local binaries into container.
MOUNT_CLI
And you can run `air` inside of `services/terminal-service` or `services/cmd-hook` to get auto recompiling.
## Development
Copy `.env.sample` to `.env` and customize it for your needs.
Run dev server (frontend, backend):
yarn dev
To develop analytics dashboard:
yarn analytics
The default username and password is `alex` and `Alex1234`.
## Testing
Unit tests:
yarn test:unit
Run Cypress locally, using the current development containers:
docker compose up -d
@@ -164,37 +78,76 @@ Run Cypress in container, create brand-new containers along with it:
# tearing off the containers
./docker-scripts.sh down
## Deployment
### Deployment
Create production env
cp .env.prod.sample .env.prod
Edit `.env.prod`
BASE_URL=https://yourdomain.com # IMPORTANT to make login work
SECRET_KEY=xxxx # Change it to something safe!
Start the service
# Build the images (only allowed for maintainers)
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml push
# Server side
docker compose -f docker-compose.prod.yml pull
yarn build:quests
docker compose -f docker-compose.prod.yml up -d
### Necessary Configuration
To enable social login, you should have OAuth client token in a `.env` file:
You can copy from `.env.sample`
You can copy from `example.env`
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
# .env
BASE_URL=https://example.com
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
SECRET_KEY=your-secret-key
To enable it:
You can generate a random key by running
docker compose --env-file .env -f docker-compose.prod.yml up -d
yarn setup
## Standalone App (without Docker)
Source: `app/`
# Connect to dev server (https://odyssey.wancat.cc)
yarn app
# Connect to localhost (http://localhost:3000)
yarn app:local
# Connect to anyhost
API_ENDPOINT=http://example.com yarn app:local
## Swagger
docker compose up -d swagger
Open http://localhost:8080 to open Swagger
## CLI terminal client (Docker not required)
yarn cli
Usage: client [options] [command]
Options:
-s, --session <string> Session ID
-c, --create Create a new session
-h, --host <string> Server host (default: "http://localhost:3000")
--help display help for command
Commands:
list List all sessions
connet to recent session:
yarn cli
connect to new session:
yarn cli -c
connect to specific session:
yarn cli -s 64d0de0367459c13004bc83f
connect to other host:
yarn cli -h https://example.com
list sessions:
yarn cli list
+18 -7
View File
@@ -1,20 +1,31 @@
# Base stage
FROM node:20-alpine AS build
FROM node:20-alpine as base
WORKDIR /app
COPY package.json yarn.lock ./
COPY package.json yarn.lock tsconfig.json ./
COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/utils/package.json ./packages/utils/package.json
COPY packages/constants/package.json ./packages/constants/package.json
COPY packages/models/package.json ./packages/models/package.json
COPY app/package.json ./app/package.json
COPY server/package.json ./server/package.json
RUN yarn install --frozen-lockfile
COPY tsconfig.json ./tsconfig.json
COPY server ./server
COPY packages ./packages
# Dev stage
FROM base as dev
WORKDIR /app/app
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
# Build stage
FROM base as build
COPY packages/file-graph ./packages/file-graph
COPY packages/utils ./packages/utils
COPY packages/constants ./packages/constants
COPY packages/models ./packages/models
COPY app ./app
RUN yarn workspace app build
# Production stage
FROM nginx:stable-alpine AS prod
FROM nginx:stable-alpine as prod
COPY --from=build /app/app/dist /usr/share/nginx/html
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+14 -16
View File
@@ -5,28 +5,27 @@
"scripts": {
"dev": "vite",
"dev:docker": "API_TARGET=http://backend:3000 vite",
"build": "vue-tsc && vite build",
"type-check": "vue-tsc",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@formbricks/js": "^2.1.1",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@trpc/client": "^11.1.0",
"@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"@linux-odyssey/models": "*",
"@types/dompurify": "^3.0.5",
"axios": "^1.20.0",
"dompurify": "^3.4.16",
"axios": "^1.6.0",
"dompurify": "^3.0.6",
"echarts": "^5.4.3",
"marked": "^10.0.0",
"pinia": "^2.1.7",
"reconnecting-websocket": "^4.4.0",
"socket.io-client": "^4.7.1",
"validator": "^13.15.22",
"validator": "^13.11.0",
"vue": "^3.3.4",
"vue-i18n": "11.3.0",
"vue-router": "^4.2.4",
"vue-toastification": "^2.0.0-rc.5",
"vue3-lottie": "^3.2.0",
@@ -36,14 +35,13 @@
},
"devDependencies": {
"@types/validator": "^13.11.9",
"@vitejs/plugin-vue": "^5.2.2",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/compiler-sfc": "^3.3.13",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.5.23",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.3",
"typescript": "~5.7.2",
"vite": "^6.4.3",
"vue-tsc": "^2.2.8"
"typescript": "^5.3.3",
"vite": "^4.5.3",
"vue-tsc": "^2.0.10"
}
}
+19 -48
View File
@@ -1,13 +1,10 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import api from '../utils/api'
const { t } = useI18n()
const emit = defineEmits(['onSubmit', 'onChange'])
defineProps({
type: {
type: String,
@@ -86,16 +83,6 @@ const handleChange = () => {
errorMessage.value = 'Something went wrong'
}
}
const registerGuest = async () => {
try {
await api.post('/auth/register-guest')
router.push({ name: 'map' })
} catch (err) {
console.error(err)
toast.error('Something went wrong.')
}
}
</script>
<template>
<!-- login page -->
@@ -113,7 +100,7 @@ const registerGuest = async () => {
:icon="['fas', 'circle-exclamation']"
class="px-1 text-sm"
/>
<span>{{ t('authform.recommand_pc_fullscreen') }}</span>
<span>建議使用電腦全螢幕執行</span>
</div>
<form @submit.prevent="handleSubmit()" class="w-full">
<div v-if="socialLogin && hasSocialLogins">
@@ -127,7 +114,7 @@ const registerGuest = async () => {
:icon="['fab', 'google']"
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
id="GitHubLogin"
@@ -139,14 +126,14 @@ const registerGuest = async () => {
:icon="['fab', 'github']"
class="text-text-secondary px-3 text-lg"
/>
<span class="font-medium"> {{ t('authform.github_login') }} </span>
<span class="font-medium"> 以 GitHub 繼續 </span>
</a>
<p
id="or"
class="text-text-secondary flex justify-center mt-3"
v-if="type === 'login' || type === 'register'"
>
{{ t('authform.or') }}
或
</p>
</div>
<div class="mb-6">
@@ -155,22 +142,20 @@ const registerGuest = async () => {
id="username"
ref="usernameInput"
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
:placeholder="
t('authform.username') +
(type === 'login' ? ' / ' + t('authform.email') : '')
"
:placeholder="type === 'login' ? '電子郵件 / 帳號名稱' : '帳號名稱'"
v-model="username"
required
@input="handleChange()"
autocomplete="username"
/>
<p
class="mx-1 text-xs font-normal text-text-secondary"
<label
class="text-sm font-normal text-text-secondary"
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 class="mb-6" v-if="type === 'register'">
<input
@@ -178,7 +163,7 @@ const registerGuest = async () => {
id="email"
ref="emailInput"
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
:placeholder="t('authform.email')"
placeholder="電子郵件"
v-model="email"
required
@input="handleChange()"
@@ -190,18 +175,11 @@ const registerGuest = async () => {
type="password"
id="password"
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
:placeholder="t('authform.password')"
placeholder="密碼"
v-model="password"
required
@input="handleChange()"
/>
<p
class="mx-1 text-xs font-normal text-text-secondary"
id="PasswordRules"
v-if="type === 'register'"
>
{{ t('authform.password_rules') }}
</p>
</div>
<p
id="ErrorDisplay"
@@ -215,39 +193,32 @@ const registerGuest = async () => {
class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full"
type="submit"
>
<span v-if="type === 'login'"> {{ t('authform.login') }} </span>
<span v-else> {{ t('authform.register') }} </span>
<span v-if="type === 'login'"> 登入 </span>
<span v-else> 註冊 </span>
</button>
<p id="SwitchLoginRegister" class="text-text flex justify-center mt-3">
<span id="CheckRegistered" v-if="type === 'register'">
{{ t('authform.already_registered') }}
已經有帳號了嗎?
<RouterLink
id="SwitchtoLogin"
class="text-text-primary font-bold"
to="/login"
>
<u> {{ t('authform.login') }} </u>
<u> 登入 </u>
</RouterLink></span
>
<span id="CheckRegistered" v-else-if="type === 'login'">
{{ t('authform.not_registered') }}
還沒有帳號嗎?
<RouterLink
id="SwitchtoRegister"
class="text-text-primary font-bold"
to="/register"
>
<u> {{ t('authform.register') }} </u>
<u> 註冊 </u>
</RouterLink></span
>
</p>
<button
id="register-guest"
class="rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
@click="registerGuest()"
>
{{ t('authform.continue_as_guest') }}
</button>
</form>
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
<template>
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
<h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1>
<div id="cheatsheets">
<p class="text-text-secondary">--指令列表--</p>
</div>
</template>
@@ -1,22 +1,17 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { ref } from 'vue'
import { Vue3Lottie } from 'vue3-lottie'
// import { surveyUrl } from '../config'
import CompleteAnimation from '../../img/complete-animation.json'
const { t } = useI18n()
import CompleteAnimation from '../img/complete-animation.json'
const showModal = ref(true) // Controls whether the modal is visible or not
const router = useRouter()
const toast = useToast()
const closeModal = () => {
showModal.value = false
}
const defineComponent = () => ({
components: {
Vue3Lottie,
@@ -27,12 +22,12 @@ const defineComponent = () => ({
}
},
})
const backtoMap = async () => {
try {
router.push({ name: 'map' })
} catch (err) {
toast.error(t('quest_map.load_quest_map_failed'))
// useToast().error('Failed to load map')
toast.error('無法讀取地圖')
console.error(err)
}
}
@@ -52,7 +47,7 @@ setTimeout(() => {
id="QuestCompleted"
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
>
{{ t('game.quest_completed') }}
關卡完成!
</h2>
<div class="z-10 right-1/2 top-1/2" @animationend="defineComponent">
<Vue3Lottie
@@ -61,35 +56,29 @@ setTimeout(() => {
:width="200"
/>
</div>
<p class="text-base text-text text-center">恭喜你完成這一關!</p>
<p class="text-base text-text text-center">
{{ t('game.quest_completed_desc_1') }}
</p>
<p class="text-base text-text text-center">
{{ t('game.quest_completed_desc_2') }}
接下來回到地圖,迎接新的挑戰吧!
</p>
<!-- <p class="text-base text-text">
您已成功完成挑戰,真的非常感謝您的參與!<br />
如果您能撥冗填寫這份問卷,我們將不勝感激,這對我們非常重要!
</p> -->
<!-- <a
id="SurveyButton"
:href="surveyUrl"
target="_blank"
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
>填寫問卷</a
> -->
<a
id="BacktoMap"
target="_blank"
@click="backtoMap"
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
>{{ t('game.back_to_map') }}</a
>回到地圖</a
>
</div>
<div style="position: relative; height: 100vh; overflow: auto">
<iframe
src="https://app.formbricks.com/s/clyffdm0c0be2wh6335clle0l"
frameborder="0"
style="
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border: 0;
"
>
</iframe>
</div>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { createSession } from '../store/session'
import { surveyUrl } from '../config'
const router = useRouter()
const reset = async () => {
console.log('resetting...')
await createSession()
}
const continuePlay = async () => {
try {
router.push({ name: 'map' })
} catch (err) {
useToast().error('無法讀取地圖')
console.error(err)
}
}
</script>
<template>
<section class="h-[10%] p-3 grid grid-cols-4 gap-4 place-content-between">
<button id="survey" class="bg-bg-secondary rounded-lg p-2">
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary"
/>
<p class="text-text-primary inline ml-1">問卷</p>
</a>
</button>
<button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
<font-awesome-icon
:icon="['far', 'circle-question']"
class="text-text-disabled"
/>
<p class="text-text-disabled inline ml-1">解答</p>
</button>
<button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
<font-awesome-icon
:icon="['fas', 'arrow-rotate-left']"
class="text-text-primary"
/>
<p class="text-text-primary inline ml-1">重來</p>
</button>
<button
id="continue"
class="bg-bg-secondary rounded-lg p-2"
@click="continuePlay"
>
<font-awesome-icon
:icon="['far', 'circle-right']"
class="text-text-primary"
/>
<p class="text-text-primary inline ml-1">繼續</p>
</button>
</section>
</template>
+1 -15
View File
@@ -20,21 +20,7 @@ function nodeClass() {
</script>
<template>
<a href="#" :class="nodeClass()">
<font-awesome-icon
v-if="node.type === 'directory'"
:icon="['far', 'folder']"
class="text-text-primary size-8"
/>
<font-awesome-icon
v-else
:icon="['far', 'file']"
class="text-text-primary size-8"
/>
<br />
{{ node.discovered ? node.name : '???' }}
{{ node.name === '' && node.discovered ? '/' : '' }}
</a>
<a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
<FileNode :node="child" :pwd="pwd" />
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { defineProps, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { logout } from '../utils/auth'
import { bugReportUrl, surveyUrl } from '../config'
import { reset } from '../store/session'
import userProfileStore, {
loadUserProfile,
resetUserProfile,
} from '../store/userProfile'
const router = useRouter()
const toast = useToast()
const handleLogout = async () => {
try {
await logout()
resetUserProfile()
reset()
router.push({ name: 'login' })
} catch (err) {
toast.error('登出失敗')
console.error(err)
}
}
defineProps({
title: {
type: String,
required: false,
},
})
onMounted(async () => {
try {
await loadUserProfile()
} catch (err) {
console.error(err)
toast.error('無法讀取使用者資料')
}
})
</script>
<template>
<div id="header" class="bg-bg-secondary content-center h-full w-full p-1">
<div class="h-full flex items-center justify-center mx-3">
<div class="flex items-center gap-3">
<!-- <button id="stream" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'bars']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="backward" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'arrow-left']"
class="text-border h-full w-full"
/>
</button> -->
<img src="../img/icon_totem.svg" class="h-5" />
<button
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap"
style="font-size: 2vh"
>
Linux Odyssey
</button>
<p
id="QuestTitle"
class="text-text inline-block font-bold whitespace-nowrap"
style="font-size: 2vh"
>
{{ title }}
</p>
</div>
<div class="h-full w-full flex gap-3 items-center justify-end">
<!-- <button id="warning" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'triangle-exclamation']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="setting" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'gear']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="fullscreen" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'expand']"
class="text-text-primary h-full w-full"
/>
</button> -->
<p
id="UsernameText"
class="text-text inline-block whitespace-nowrap"
style="font-size: 2vh"
>
{{ userProfileStore.username }}
</p>
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary h-full w-full"
/>
</a>
<RouterLink title="LeaderBoard" to="/leaderboard" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'trophy']"
class="text-text-primary h-full w-full"
/>
</RouterLink>
<RouterLink title="Map" to="/map" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'map']"
class="text-text-primary h-full w-full"
/>
</RouterLink>
<a
title="Bug Report"
:href="bugReportUrl"
target="_blank"
class="h-5 w-5"
>
<font-awesome-icon
:icon="['fas', 'bug']"
class="text-text-primary h-full w-full"
/>
</a>
<button title="Sign Out" @click="handleLogout" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'arrow-right-from-bracket']"
class="text-text-primary h-full w-full"
/>
</button>
</div>
</div>
</div>
</template>
@@ -1,26 +1,16 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, ref, watch } from 'vue'
import { useSession } from '../../store/session'
import MarkdownText from '../MarkdownText.vue'
import { ref, watch } from 'vue'
import sessionStore from '../store/session'
import MarkdownText from './MarkdownText.vue'
const { t } = useI18n()
const sessionStore = useSession()
const current = ref(-1)
const hintDetails = ref<HTMLDetailsElement | null>(null)
const length = computed(() => {
return sessionStore.session?.responses?.length ?? 0
})
const disabled = computed(() => {
return current.value === length.value - 1
})
watch(length, () => {
console.log('length update', length.value)
current.value = length.value - 1
})
watch(
() => sessionStore.session.hints,
(hints) => {
current.value = hints.length - 1
},
{ deep: true }
)
const left = () => {
if (current.value > 0) {
@@ -29,34 +19,23 @@ const left = () => {
}
const right = () => {
if (current.value < length.value - 1) {
if (current.value < sessionStore.session.hints.length - 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>
<template>
<div class="h-full flex flex-col">
<!-- Hint Header -->
<div class="flex items-center p-2 h-10">
<font-awesome-icon
:icon="['far', 'lightbulb']"
class="text-yellow-200 p-2 content-center"
/>
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">
{{ t('game.description') }}
<!-- Hint -->
提示
</h1>
<div v-if="current !== -1" class="flex w-full justify-end items-end">
<button
@@ -74,38 +53,35 @@ watch(response, () => {
<p class="inline text-text font-xl w-1/14 p-2 m-1">{{ current + 1 }}</p>
<p class="inline text-text font-xl w-1/14 p-2 m-1">/</p>
<p class="inline text-text font-xl w-1/14 p-2 m-1">
{{ length }}
{{ sessionStore.session.hints.length }}
</p>
<button
class="p-2 m-1 w-1/8"
id="Rbutton"
@click="right"
:disabled="disabled"
:disabled="current === sessionStore.session.hints.length - 1"
>
<font-awesome-icon
:icon="['fas', 'arrow-right']"
:class="disabled ? 'text-gray-600' : 'text-text'"
/>
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
</button>
</div>
</div>
<div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul>
<li
v-if="response"
v-for="response in sessionStore.session.responses[current]"
:key="response.type"
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
v-if="response?.hint"
v-for="hint in sessionStore.session.hints[current]"
:key="hint"
class="text-text-primary font-xl whitespace-pre-wrap"
>
<details ref="hintDetails">
<summary class="cursor-pointer">{{ t('game.hint') }}</summary>
<MarkdownText :content="response.hint" />
</details>
<MarkdownText :content="hint" />
</li>
</ul>
<br />
+4 -27
View File
@@ -25,37 +25,14 @@ const html = computed(() => {
line-height: 1;
}
.markdown-content :deep(p) {
line-height: 1.5;
}
.markdown-content :deep(ol) {
list-style: decimal;
padding-left: 1em;
line-height: 1.2;
}
.markdown-content :deep(ul) {
list-style: disc;
padding-left: 1em;
line-height: 1.2;
}
.markdown-content :deep(pre) {
background-color: #2e3440;
border-radius: 0.5rem;
padding: 0.5rem;
font-size: 0.9rem;
color: #d8dee9;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #4c566a;
line-height: 1.5;
font-family: 'monospace';
}
.markdown-content :deep(code:not(pre code)) {
background-color: #2e3440;
.markdown-content :deep(code) {
background-color: #282a33;
border-radius: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.9rem;
color: #d8dee9;
font-family: 'monospace';
padding: 0.25rem;
font-size: 0.875rem;
}
</style>
-131
View File
@@ -1,131 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { ref, defineProps, onMounted } from 'vue'
import MarkdownText from './MarkdownText.vue'
import { trpc } from '../utils/trpc'
const { t, locale } = useI18n()
const props = defineProps({
questTitle: {
type: String,
required: true,
},
questId: {
type: String,
required: true,
},
questUnlocked: {
type: Boolean,
required: true,
},
})
const emit = defineEmits(['closeIntro'])
const showIntro = ref(true) // Controls whether the modal is visible or not
const router = useRouter()
const toast = useToast()
const { questTitle, questId, questUnlocked } = props
const questInstruction = ref('')
const questColor = ref('')
const questTextColor = ref('')
const closeModal = () => {
showIntro.value = false
emit('closeIntro', true)
router.push({ name: 'map' })
}
const handleQuests = (id: string) => {
if (questUnlocked) {
router.push({ name: 'game', params: { questId: id } })
} else {
toast.warning(t('quest.locked'))
}
}
const handleColour = () => {
if (questUnlocked) {
questColor.value = '#00ff00'
questTextColor.value = '#000000'
} else {
questColor.value = '#505050'
questTextColor.value = '#a0a0a0'
}
}
onMounted(async () => {
try {
const quest = await trpc.quests.getQuestDetail.query({
questId,
locale: locale.value,
})
questInstruction.value = quest.instruction
} catch (error) {
toast.error(t('quest.load_quest_instruction_failed'), error)
}
handleColour()
})
</script>
<template>
<div v-if="showIntro" class="modal">
<div class="modal-content bg-bg-secondary rounded-lg">
<span class="close text-text content-between" @click="closeModal"
>&times;</span
>
<h2
id="QuestTitle"
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
>
{{ questTitle }}
</h2>
<div class="w-full h-3/5 p-8">
<div class="px-5 whitespace-pre-wrap">
<MarkdownText
class="text-text markdown-content"
:content="questInstruction"
/>
</div>
</div>
<a
id="StartAdventure"
target="_blank"
@click="handleQuests(questId)"
:style="{ backgroundColor: questColor, color: questTextColor }"
class="inline-flex justify-center rounded-lg text-base font-black py-3 mt-5 w-full"
>
{{ t('quest.start') }}
</a>
</div>
</div>
</template>
<style scoped>
.modal {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1;
}
.modal-content {
position: relative;
margin: 10% auto;
padding: 20px;
width: 60%;
height: 50%;
}
.close {
position: absolute;
top: 0;
right: 10px;
font-size: 30px;
cursor: pointer;
}
.markdown-content {
text-align: left;
}
</style>
+162 -227
View File
@@ -1,254 +1,189 @@
<!-- eslint-disable no-console -->
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref, onMounted, watch, computed } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { DAG } from '../../../packages/utils'
import { trpc } from '../utils/trpc'
import useUserProfile from '../store/userProfile'
import QuestIntro from './QuestIntro.vue'
import { use, init } from 'echarts/core'
import { GraphChart } from 'echarts/charts'
import { SVGRenderer } from 'echarts/renderers'
import {
TitleComponent,
TooltipComponent,
DataZoomComponent,
} from 'echarts/components'
import { DAG } from '@linux-odyssey/utils'
import api from '../utils/api'
import { NodeImage } from '../img/svg.js'
const { t, locale } = useI18n()
const store = useUserProfile()
const toast = useToast()
const marginX = 500
const marginY = 300
const chartContainer = ref(null)
let chartInstance = null
const fullwidth = window.screen.width
const marginX = 120
const marginY = 120
use([
GraphChart,
SVGRenderer,
TitleComponent,
TooltipComponent,
DataZoomComponent,
])
const nodeWidth = 160
const nodeHeight = 50
async function getQuests() {
try {
const res = await api.get('/quests')
return res.data
} catch (err) {
console.error(err)
const opened = ref<Node | null>(null)
type Node = {
id: string
title: string
x: number
y: number
index: number
completed: boolean
unlocked: boolean
throw err
}
}
type Edge = {
source: Node
target: Node
async function getProgress() {
try {
const res = await api.get('/users/me')
return res.data.progress
} catch (err) {
console.error(err)
throw err
}
}
const graphData = ref<{ nodes: Node[]; edges: Edge[] }>({
nodes: [],
edges: [],
const genOption = (nodes: any, edges: any) => ({
renderer: 'svg',
tooltip: {},
animationDurationUpdate: 1500,
animationEasingUpdate: 'quinticInOut',
// dataZoom: [
// {
// type: 'slider', // This type means "slider data zoom" which provides a slider bar for zooming
// orient: 'vertical', // This makes the data zoom component vertical
// start: 0, // The starting percentage of the window out of the data extent
// end: 100, // The ending percentage of the window out of the data extent
// show: true,
// },
// ],
series: [
{
type: 'graph',
layout: 'none',
symbol: () => {
return NodeImage
},
symbolSize: [fullwidth / 13, fullwidth / 30],
roam: 'move',
zoom: 1.5,
center: ['120%', '30%'],
label: {
show: true,
fontSize: fullwidth / 90,
},
edgeSymbol: ['pin', 'arrow'],
edgeSymbolSize: [2, 15],
edgeLabel: {
fontSize: 25,
},
data: nodes,
// links: [],
links: edges,
lineStyle: {
width: 3,
color: 'gray',
opacity: 4,
curveness: 0.08,
},
itemStyle: {
color: (params: any) => {
const {
data: { completed, unlocked },
} = params
if (completed) {
return '#00ff00'
}
if (unlocked) {
return '#ADADB5'
}
return '#505050'
},
},
},
],
})
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const offsetX = ref(0)
const offsetY = ref(0)
function getOption(quests: any, progress: any) {
const dag = new DAG(quests)
function startDrag(event: PointerEvent) {
isDragging.value = true
dragStartX.value = event.clientX - offsetX.value
dragStartY.value = event.clientY - offsetY.value
}
function drag(event: PointerEvent) {
if (!isDragging.value) return
// Capture only once dragging starts so plain clicks still reach the node buttons
const el = event.currentTarget as HTMLElement
if (!el.hasPointerCapture(event.pointerId))
el.setPointerCapture(event.pointerId)
offsetX.value = event.clientX - dragStartX.value
offsetY.value = event.clientY - dragStartY.value
}
function endDrag() {
isDragging.value = false
}
async function computeGraphData() {
if (!store.progress) return
try {
const quests = await trpc.quests.getQuests.query({ locale: locale.value })
const dag = new DAG(quests)
const nodesValues = dag.getNodes()
const nodes = nodesValues.map((node) => ({
id: node.id,
title: node.title,
x: marginX * node.layer * 2,
// Centered on 0; the world container is anchored at the viewport's vertical center
y: (node.index - (dag.getLayer(node.id) + 1) / 2) * marginY,
completed: store.progress[node.id]?.completed || false,
unlocked: node.requirements.every(
(req: string) => store.progress[req]?.completed
),
index: node.index,
}))
const edges = dag
.getEdgesArray()
.map((edge) => ({
source: nodes.find((n) => n.id === edge[0]),
target: nodes.find((n) => n.id === edge[1]),
}))
.filter(
(edge): edge is Edge =>
edge.source !== undefined && edge.target !== undefined
)
graphData.value = { nodes, edges }
} catch (error) {
console.error(t('quest_map.compute_graph_failed'), error)
toast.error(t('quest_map.load_quest_data_failed'))
const nodes = dag.getNodes().map((node) => ({
id: node._id,
name: node.title,
y: marginX * node.index - (marginX * dag.getLayer(node._id)) / 2,
x: marginY * node.layer * 2,
completed: progress[node._id]?.completed || false,
unlocked: node.requirements.every((req: any) => progress[req]?.completed),
}))
const lineAppearence = (node: any) => {
try {
const { unlocked } = node
return {
type: unlocked ? 'line' : 'dashed',
color: unlocked ? '#ADADB5' : '#454552',
}
} catch (err) {
console.error(err)
throw err
}
}
const edges = dag.getEdgesArray().map((edge: any) => ({
source: edge[0],
target: edge[1],
lineStyle: lineAppearence(nodes.find((node) => node.id === edge[1])),
}))
return genOption(nodes, edges)
}
function handleNodeClick(node: Node) {
opened.value = node
}
function closeIntro(close: boolean) {
if (close) {
opened.value = null
}
const router = useRouter()
const toast = useToast()
function initChart(option: any) {
chartInstance = init(chartContainer.value)
chartInstance.setOption(option)
chartInstance.on('click', (params: any) => {
const {
data: { id, unlocked },
} = params
if (unlocked) {
router.push({ name: 'game', params: { questId: id } })
} else {
toast.warning('你還沒完成前一個關卡!')
}
})
}
onMounted(async () => {
await store.loadUserProfile()
await computeGraphData()
})
watch(() => store.progress, computeGraphData, { deep: true })
watch(locale, () => {
opened.value = null
computeGraphData()
})
const edgePath = computed(() => {
return (source: Node, target: Node) => {
const mx = (source.x + target.x) / 2
return `M${source.x},${source.y}C${mx},${source.y} ${mx},${target.y} ${target.x},${target.y}`
}
})
const nodeStyle = computed(() => {
return (node: Node) => {
if (node.completed) return 'completed'
if (node.unlocked) return 'unlocked'
return 'locked'
}
})
const edgeStyle = computed(() => {
return (edge: Edge) => {
return edge.target.unlocked ? 'unlocked' : 'locked'
}
const quests = await getQuests()
const progress = await getProgress()
const option = getOption(quests, progress)
initChart(option)
})
</script>
<template>
<div class="relative bg-black w-full h-full flex">
<div
class="flex place-content-center w-full bg-catelogbg bg-cover bg-scroll"
<img
src="../img/catelogbg.svg"
alt="bg"
class="p-2 stroke-2 scale-y-120 justify-center items-center"
/>
<h1
class="p-4 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
>
<h1
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"
>
{{ t('quest_map.intro') }}
</h1>
<div
class="absolute inset-0 overflow-hidden select-none"
:class="isDragging ? 'cursor-grabbing' : 'cursor-grab'"
style="touch-action: none"
@pointerdown="startDrag"
@pointermove="drag"
@pointerup="endDrag"
@pointercancel="endDrag"
>
<div
class="absolute top-1/2 left-0"
:style="{ transform: `translate(${offsetX}px, ${offsetY}px)` }"
>
<svg class="absolute overflow-visible pointer-events-none">
<path
v-for="edge in graphData.edges"
:key="`${edge.source.id}-${edge.target.id}`"
:d="edgePath(edge.source, edge.target)"
:class="['edge', edgeStyle(edge)]"
/>
</svg>
<button
v-for="node in graphData.nodes"
:key="node.id"
type="button"
:class="['node', nodeStyle(node)]"
:style="{
left: `${node.x - nodeWidth / 2}px`,
top: `${node.y - nodeHeight / 2}px`,
width: `${nodeWidth}px`,
height: `${nodeHeight}px`,
}"
@click="handleNodeClick(node)"
>
<span class="line-clamp-2">{{ node.title }}</span>
</button>
</div>
</div>
<QuestIntro
:questTitle="opened.title"
:questId="opened.id"
:questUnlocked="opened.unlocked"
v-if="opened"
@close-intro="closeIntro"
/>
踏上你的Linux冒險之旅吧!
</h1>
<div class="flex flex-wrap absolute w-full h-full z-1">
<div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
</div>
</div>
</template>
<style scoped>
.node {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.5rem;
text-align: center;
border-radius: 10px;
cursor: pointer;
transition:
filter 0.15s,
box-shadow 0.15s;
}
.node:hover {
filter: brightness(1.15);
}
.node:focus-visible {
outline: none;
box-shadow: 0 0 0 3px #00ff00;
}
.node.completed {
background-color: #6cf76c;
color: #1d1d1d;
}
.node.unlocked {
background-color: #8c8c92;
color: #ffffff;
}
.node.locked {
background-color: #505050;
color: #a0a0a0;
}
.edge {
stroke-width: 3;
fill: none;
}
.edge.unlocked {
stroke: #adadb5;
stroke-dasharray: none;
}
.edge.locked {
stroke: #454552;
stroke-dasharray: 5, 5;
}
</style>
@@ -1,15 +1,11 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import MarkdownText from '../MarkdownText.vue'
import { useSession } from '../../store/session'
import MarkdownText from './MarkdownText.vue'
import sessionStore from '../store/session'
import { Task } from '../types'
const { t } = useI18n()
const sessionStore = useSession()
const tasks = computed(() => {
console.log('session', sessionStore.session)
return sessionStore.session?.tasks.map((task) => {
return sessionStore.session.tasks.map((task: Task) => {
const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text'
return {
@@ -34,14 +30,14 @@ const tasks = computed(() => {
:content="sessionStore.quest.instruction"
/>
<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">
<li v-for="task in tasks" :key="task.id">
<MarkdownText :class="task.class" :content="task.content" />
</li>
</ul>
</div>
<p class="text-text" v-else>{{ t('game.loading') }}</p>
<p class="text-text" v-else>Loading...</p>
</div>
</div>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useTerminal } from '../store/session'
const terminal = ref<HTMLElement | null>(null)
onMounted(() => {
const term = useTerminal()
if (!terminal.value) return
term.mount(terminal.value)
window.addEventListener('resize', () => term.resizeScreen())
})
</script>
<template>
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
<button id="Terminal" class="text-text h-[5%]">終端機</button>
<div
id="terminal"
ref="terminal"
class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
></div>
</template>
@@ -1,8 +1,6 @@
<script setup lang="ts">
import FileNode from '../FileNode.vue'
import { useSession } from '../../store/session'
const sessionStore = useSession()
import FileNode from './FileNode.vue'
import sessionStore from '../store/session'
</script>
<template>
@@ -10,7 +8,6 @@ const sessionStore = useSession()
<ul>
<li>
<FileNode
v-if="sessionStore.session"
:node="sessionStore.session.graph"
:pwd="sessionStore.session.pwd"
/>
@@ -48,14 +45,14 @@ const sessionStore = useSession()
position: absolute;
top: 0;
right: 50%;
border-top: 2px solid #00ff00;
border-top: 1px solid #00ff00;
width: 50%;
height: 20px;
}
.tree li::after {
right: auto;
left: 50%;
border-left: 2px solid #00ff00;
border-left: 1px solid #00ff00;
}
.tree li:only-child::after,
@@ -73,7 +70,7 @@ const sessionStore = useSession()
}
.tree li:last-child::before {
border-right: 2px solid #00ff00;
border-right: 1px solid #00ff00;
border-radius: 0 5px 0 0;
-webkit-border-radius: 0 5px 0 0;
-moz-border-radius: 0 5px 0 0;
@@ -89,7 +86,7 @@ const sessionStore = useSession()
position: absolute;
top: 0;
left: 50%;
border-left: 2px solid #00ff00;
border-left: 1px solid #00ff00;
width: 0;
height: 20px;
}
@@ -100,7 +97,7 @@ const sessionStore = useSession()
text-decoration: none;
color: #ffffff;
font-family: arial, verdana, tahoma;
font-size: 12px;
font-size: 11px;
display: inline-block;
border-radius: 5px;
@@ -112,10 +109,18 @@ const sessionStore = useSession()
-moz-transition: all 0.5s;
}
.tree li a.pwd {
border: 3px solid #00ff00;
background: #008080;
font-weight: bold;
font-size: 13px;
a.discovered {
background: #526b4e;
color: #000;
border: 1px solid #94a0b4;
}
ul.discovered,
li.discovered {
border-color: #94a0b4;
}
a.pwd {
box-shadow: 0 0 40px rgba(255, 255, 0, 20);
}
</style>
@@ -1,15 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
<h1 id="CommandBook" class="inline text-text-primary font-xl">
{{ t('game.command_book') }}
</h1>
<div id="cheatsheets">
<p class="text-text-secondary">-- --{{ t('game.cheatsheets') }}-- --</p>
</div>
</template>
-116
View File
@@ -1,116 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { useSession } from '../../store/session'
import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
import { trpc } from '../../utils/trpc'
import type { QuestLocale } from '../../../../packages/constants'
const { t, locale } = useI18n()
const toast = useToast()
const router = useRouter()
const sessionStore = useSession()
const reset = async () => {
console.log('resetting...')
const selectedLocale = locale.value as QuestLocale
const questId = sessionStore.session?.quest
if (!questId) return
try {
const quests = await trpc.quests.getQuests.query({
locale: selectedLocale,
})
console.log({
selectedLocale,
questId,
availableQuestIds: quests.map((quest) => quest.id),
})
if (!quests.some((quest) => quest.id === questId)) {
toast.warning(t('game.language_unavailable'))
return
}
await sessionStore.createSession()
} catch (error) {
console.error('Failed to reset quest:', error)
toast.error(t('game.reset_failed'))
}
}
const continuePlay = async () => {
try {
router.push({ name: 'map' })
} catch (err) {
useToast().error(t('quest_map.load_quest_map_failed'))
console.error(err)
}
}
</script>
<template>
<section class="mt-2 flex flex-row gap-2 justify-center">
<button
id="survey"
:title="t('game_nav.survey')"
class="bg-bg-secondary rounded-lg p-2"
@click="openQuestSurvey"
>
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary px-1"
/>
<p class="text-text-primary text-xs">{{ t('game_nav.survey') }}</p>
</button>
<button
id="bug-report"
:title="t('game_nav.bug_report')"
@click="openBugReport"
class="bg-bg-secondary rounded-lg p-2"
>
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
<p class="text-text-primary text-xs">
{{ t('game_nav.bug_report') }}
</p>
</button>
<!-- <button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
<font-awesome-icon
:icon="['far', 'circle-question']"
class="text-text-disabled px-1"
/>
<p class="text-text-disabled inline ml-1 md:flex">解答</p>
</button> -->
<button
id="reset"
:title="t('game.reset')"
class="bg-bg-secondary rounded-lg p-2"
@click="reset"
>
<font-awesome-icon
:icon="['fas', 'arrow-rotate-left']"
class="text-text-primary px-1"
/>
<p class="text-text-primary text-xs">
{{ t('game.reset') }}
</p>
</button>
<button
id="continue"
:title="t('game.continue')"
class="bg-bg-secondary rounded-lg p-2"
@click="continuePlay"
>
<font-awesome-icon
:icon="['far', 'circle-right']"
class="text-text-primary px-1"
/>
<p class="text-text-primary text-xs">
{{ t('game.continue') }}
</p>
</button>
</section>
</template>
-40
View File
@@ -1,40 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { computed } from 'vue'
import { useSession } from '../../store/session'
const { t } = useI18n()
const toast = useToast()
const sessionStore = useSession()
const showCover = computed(() => sessionStore.session === null)
const startSession = async () => {
try {
await sessionStore.createSession()
} catch (err: any) {
console.error(err)
toast.error(err.message)
}
}
</script>
<template>
<div
v-if="showCover"
class="bg-bg-secondary h-full w-1/2 fixed bg-opacity-50 flex justify-center items-center"
>
<button
id="start"
class="bg-bg-secondary rounded-lg p-3"
@click="startSession"
>
<font-awesome-icon
:icon="['fas', 'play']"
class="text-text-primary h-full px-3"
/>
<p class="text-text-primary inline ml-1 md:flex">{{ t('game.start') }}</p>
</button>
</div>
</template>
-47
View File
@@ -1,47 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { onMounted, onUnmounted, ref } from 'vue'
import { useSession, useTerminal } from '../../store/session'
const { t } = useI18n()
const sessionStore = useSession()
const terminal = ref<HTMLElement | null>(null)
const term = useTerminal()
onMounted(() => {
if (!terminal.value) return
term.mount(terminal.value)
const handleResize = () => term.resizeScreen()
window.addEventListener('resize', handleResize)
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
})
</script>
<template>
<div class="flex flex-col h-full">
<div class="flex items-center gap-2 p-1">
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text" />
<p id="Terminal" class="text-text">{{ t('game.terminal') }}</p>
<a
class="text-xs font-normal text-gray-200 ml-10 bg-bg-primary rounded-lg px-2 py-1 hover:bg-bg-primary-hover transition-colors duration-300"
:href="`/terminal/${sessionStore.session?.containerName}?token=${sessionStore.session?.token}`"
target="_blank"
>
{{ t('game.open_terminal_in_new_tab') }}
</a>
</div>
<div
id="terminal"
ref="terminal"
class="bg-bg-secondary flex-grow overflow-hidden px-2"
style="height: calc(100vh - 40px)"
></div>
</div>
</template>
@@ -1,113 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { logout } from '../../utils/auth'
import { trpc } from '../../utils/trpc'
import { openBugReport } from '../../utils/formbricks'
import { useSession } from '../../store/session'
import useUserProfile from '../../store/userProfile'
const { t } = useI18n()
const userStore = useUserProfile()
const sessionStore = useSession()
const router = useRouter()
const toast = useToast()
const handleLogout = async () => {
try {
await logout()
userStore.resetUserProfile()
sessionStore.reset()
router.push({ name: 'login' })
} catch (err) {
toast.error(t('game_nav.sign_out_failed'))
console.error(err)
}
}
const openSurvey = async () => {
const surveyUrl = await trpc.links.surveyLink.query()
window.open(surveyUrl, '_blank')
}
onMounted(async () => {
try {
await userStore.loadUserProfile()
} catch (err) {
toast.error(t('game_nav.load_user_profile_failed'))
console.error(err)
}
})
</script>
<template>
<!-- <button id="warning" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'triangle-exclamation']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="setting" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'gear']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="fullscreen" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'expand']"
class="text-text-primary h-full w-full"
/>
</button> -->
<p
id="UsernameText"
class="text-text inline-block whitespace-nowrap px-1.5"
style="font-size: 2vh"
>
{{ userStore.username }}
</p>
<button
:title="t('game_nav.survey')"
target="_blank"
class="md:h-5 md:w-5 px-1.5 w-auto"
@click="openSurvey"
>
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary inline"
/>
</button>
<RouterLink
:title="t('game_nav.leaderboard')"
to="/leaderboard"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon :icon="['fas', 'trophy']" class="text-text-primary" />
</RouterLink>
<RouterLink
:title="t('game_nav.map')"
to="/map"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
</RouterLink>
<button
:title="t('game_nav.bug_report')"
@click="openBugReport"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
</button>
<button
:title="t('game_nav.sign_out')"
@click="handleLogout"
class="md:h-5 md:w-5 px-1.5 mr-2 w-auto"
>
<font-awesome-icon
:icon="['fas', 'arrow-right-from-bracket']"
class="text-text-primary"
/>
</button>
</template>
-72
View File
@@ -1,72 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import LanguageSelector from './LanguageSelector.vue'
const { t } = useI18n()
defineProps({
title: {
type: String,
required: false,
},
headerComponent: {
type: Object,
required: false,
},
})
const menuOpen = ref(false)
const windowWidth = window.innerWidth < 768
const toggleMenu = () => {
menuOpen.value = !menuOpen.value
}
</script>
<template>
<div
class="w-full top-0 px-2 fixed flex flex-row flex-wrap bg-bg-secondary justify-between items-center sm:justify-between"
>
<div class="flex flex-row items-center gap-3" :class="{ hidden: menuOpen }">
<img src="../../img/icon_totem.svg" class="h-8 justify-self-start" />
<RouterLink
to="/"
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap pt-1 transition ease-in-out hover:scale-110 duration-300"
style="font-size: 2vh"
>
Linux Odyssey
</RouterLink>
<p
id="QuestTitle"
class="text-text inline-block font-bold whitespace-nowrap"
style="font-size: 2vh"
>
{{ title }}
</p>
</div>
<div
class="w-full flex flex-col items-center justify-end sm:w-auto sm:block sm:ml-6"
:class="{ hidden: !menuOpen }"
>
<component :is="headerComponent" />
<LanguageSelector />
</div>
<div
v-if="windowWidth"
:class="{ 'w-full flex justify-end pb-3': menuOpen }"
>
<button
type="button"
class="flex items-center h-5 w-5 rounded-md sm:hidden"
aria-controls="mobile-menu"
aria-expanded="false"
@click="toggleMenu"
>
<font-awesome-icon
:icon="['fas', 'bars']"
class="text-text-primary h-full w-full"
/>
</button>
</div>
</div>
</template>
@@ -1,77 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { bugReportUrl } from '../../config'
const { t } = useI18n()
const scrollToSection = (sectionId: string) => {
const section = document.getElementById(sectionId)
if (section) {
const container = document.querySelector('.overflow-y-auto') as HTMLElement // Replace '.overflow-y-auto' with the appropriate class or selector for your container
if (container) {
container.scrollTo({
top: section.offsetTop - container.offsetTop,
behavior: 'smooth', // Add smooth scrolling behavior if desired
})
}
}
}
</script>
<template>
<button
id="Intro"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('intro')"
style="font-size: 2vh"
>
{{ t('landing_nav.intro') }}
</button>
<button
id="Feature"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('feature')"
style="font-size: 2vh"
>
{{ t('landing_nav.features') }}
</button>
<button
id="Info"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('about')"
style="font-size: 2vh"
>
{{ t('landing_nav.about') }}
</button>
<button
id="Contact"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('contact')"
style="font-size: 2vh"
>
{{ t('landing_nav.contact') }}
</button>
<RouterLink
id="SignIn"
to="/register"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
style="font-size: 2vh"
>
{{ t('landing_nav.login') }}
</RouterLink>
<a
:title="t('landing_nav.report_bug')"
:href="bugReportUrl"
target="_blank"
class="h-5 w-5"
>
<font-awesome-icon
:icon="['fas', 'bug']"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
/>
</a>
</template>
@@ -1,33 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { setLocale } from '../../i18n'
import type { QuestLocale } from '../../../../packages/constants'
import {
SUPPORTED_LOCALES,
LOCALE_NAME_MAP,
} from '../../../../packages/constants'
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 '../../../packages/constants'
import { SUPPORTED_LOCALES } from '../../../packages/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": "回到地圖"
}
}
File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+27 -33
View File
@@ -3,30 +3,27 @@ import { library } from '@fortawesome/fontawesome-svg-core'
/* import specific icons */
import {
faArrowLeft,
faArrowRight,
faArrowRightFromBracket,
faArrowRotateLeft,
faBars,
faArrowLeft,
faList,
faGear,
faTriangleExclamation,
faExpand,
faTerminal,
faArrowRotateLeft,
faArrowRightFromBracket,
faArrowRight,
faBug,
faCircleExclamation,
faEnvelope,
faExpand,
faFileInvoice,
faGear,
faList,
faMap,
faPlay,
faTerminal,
faTriangleExclamation,
faTrophy,
faFileInvoice,
faEnvelope,
} from '@fortawesome/free-solid-svg-icons'
import {
faLightbulb,
faCircleQuestion,
faCircleRight,
faFile,
faFolder,
faLightbulb,
} from '@fortawesome/free-regular-svg-icons'
import {
faFacebook,
@@ -36,29 +33,26 @@ import {
/* add icons to the library */
library.add(
faArrowLeft,
faArrowRight,
faArrowRightFromBracket,
faArrowRotateLeft,
faBars,
faBug,
faCircleExclamation,
faEnvelope,
faExpand,
faFileInvoice,
faGear,
faArrowLeft,
faList,
faMap,
faPlay,
faTerminal,
faGear,
faTriangleExclamation,
faExpand,
faTerminal,
faArrowRotateLeft,
faArrowRightFromBracket,
faArrowRight,
faBug,
faMap,
faTrophy,
faCircleExclamation,
faLightbulb,
faCircleQuestion,
faCircleRight,
faFile,
faFolder,
faLightbulb,
faFacebook,
faGithub,
faGoogle
faGoogle,
faFileInvoice,
faFacebook,
faEnvelope
)
+9 -17
View File
@@ -1,30 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' // import font awesome icon component
import { i18n } from './i18n'
import Toast, { POSITION } from 'vue-toastification' // import Vue Toastification
import Vue3Lottie from 'vue3-lottie' // import Vue Lottie
import './style.css'
/* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import './library'
/* import Vue Toastification */
import Toast, { POSITION } from 'vue-toastification'
/* import Vue Lottie */
import Vue3Lottie from 'vue3-lottie'
import 'vue-toastification/dist/index.css'
import App from './App.vue'
import router from './router'
import formbricks from './utils/formbricks'
import './style.css'
import './library'
const app = createApp(App)
const pinia = createPinia()
app.component('font-awesome-icon', FontAwesomeIcon)
app.use(pinia)
app.use(router)
app.use(i18n)
router.afterEach(() => {
if (typeof formbricks !== 'undefined') {
formbricks.registerRouteChange()
}
})
const options = {
position: POSITION.TOP_RIGHT,
+141 -120
View File
@@ -1,135 +1,156 @@
import { defineStore } from 'pinia'
import type { QuestLocale } from '../../../packages/constants'
import {
FileGraph,
type FileGraphUpdateEvent,
} from '../../../packages/file-graph'
import type { IResponse, ITask } from '../../../packages/game'
import type { SessionDetail } from '../../../server/src/routers/sessionRouter'
import { reactive } from 'vue'
import { useToast } from 'vue-toastification'
import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
import { IQuest } from '@linux-odyssey/models'
import api from '../utils/api'
import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal'
import type { Session } from '../types'
import { trpc } from '../utils/trpc'
import { i18n } from '../i18n'
import { LoadQuestError, LoadSessionError } from '../utils/errors'
import { Session, StageResponse } from '../types'
const socket = new Socket()
const term = new SocketTerminal()
let hasSetup = false
const toast = useToast()
interface QuestDetailResponse {
id: string
title: string
instruction: string
requirements: string[]
}
interface SessionUpdate {
status: string
responses: IResponse[]
tasks: ITask[]
graphUpdate: FileGraphUpdateEvent
function newSession() {
const graph = new FileGraph({
path: '/',
type: 'folder',
discovered: false,
})
return {
_id: '',
status: 'inactive',
graph,
pwd: '',
hints: [],
tasks: [],
responses: [],
}
}
interface Store {
session: Session | null
session: Session
questId: string
quest: QuestDetailResponse | null
quest: IQuest | null
}
export const useSession = defineStore('session', {
state: (): Store => ({
session: null,
questId: '',
quest: null,
}),
actions: {
async setQuest(questId: string, locale: QuestLocale) {
this.quest = await trpc.quests.getQuestDetail.query({
questId,
locale,
})
this.questId = questId
},
async setSession(session: SessionDetail) {
await this.setQuest(session.quest, session.locale)
this.session = {
...session,
graph: new FileGraph(session.graph),
pwd: '/home/commander',
containerName: session.containerName || '',
}
term.reset()
await socket.connect(this.session)
await term.connect(
`/terminal/${session.containerName}/ws?token=${session.token}`
)
term.focus()
},
async createSession() {
const locale = i18n.global.locale.value
const session = await trpc.session.createSession.mutate({
questId: this.questId,
locale,
})
await this.setSession(session)
term.send('echo start\n')
},
async getActiveSession(questId: string) {
const session = await trpc.session.getActiveSession.query({ questId })
if (session) {
await this.setSession(session)
}
},
newUpdate(update: SessionUpdate) {
if (!this.session) return
this.session.responses = update.responses
this.session.tasks = update.tasks
this.session.status = update.status
// if (
// response.status === 'finished' &&
// this.session.status !== 'finished'
// ) {
// this.finish()
// }
// this.session.status = response.status
},
finish() {
if (!this.session) return
this.session.status = 'finished'
},
reset() {
socket.reset()
term.reset()
this.$reset()
},
setup() {
if (hasSetup) return
// socket.on('terminal', (data: string) => {
// term.write(data)
// })
// term.onData((data: string) => {
// socket.emit('terminal', data)
// })
socket.on('graph', (event: FileGraphUpdateEvent) => {
if (!this.session) return
this.session.graph.handleEvent(event)
if (event.pwd) {
this.session.pwd = event.pwd
}
})
socket.on('update', (update: SessionUpdate) => {
this.newUpdate(update)
})
hasSetup = true
},
setStatus(status: string) {
if (!this.session) return
this.session.status = status
},
},
const store = reactive<Store>({
session: newSession(),
questId: '',
quest: null,
})
const socket = new Socket()
const term = new SocketTerminal(40, 80)
function setQuest(questId: string) {
return api.get(`/quests/${questId}`).then((res) => {
store.questId = res.data._id
store.quest = res.data
})
}
async function setSession(session: Session) {
try {
console.log('Setting session...', session)
store.session = session
store.session.graph = new FileGraph(session.graph)
term.reset()
await socket.connect(session)
term.focus()
} catch (err: any) {
console.error(err)
if (err instanceof Error) {
toast.error(err.message)
} else {
toast.error('Failed to connect to the session. Please try again later.')
}
throw err
}
}
export async function createSession() {
try {
console.log('Creating a new session...')
const res = await api.post('/sessions', { questId: store.questId })
const { data } = res
await setSession(data)
} catch (err) {
console.error(err)
toast.error('Failed to create a new session. Please try again later.')
}
}
async function getActiveSession(questId: string) {
try {
const res = await api.post('/sessions/active', { questId })
await setSession(res.data)
} catch (err) {
console.error(err)
toast.warning('Failed to connect previous session. Creating a new one...')
await createSession()
}
}
function updateGraph(event: { discover: FileObject[]; pwd: string }) {
if (event.discover) {
store.session.graph.discover(event.discover)
}
if (event.pwd) {
store.session.pwd = event.pwd
}
}
function newResponse(response: StageResponse) {
console.log('newResponse', response, 'session', store.session)
store.session.responses.push(response.responses)
store.session.hints.push(response.hints)
store.session.tasks = response.tasks
store.session.status = response.status
}
export function useTerminal() {
return term
}
export function reset() {
socket.reset()
term.reset()
store.session = newSession()
store.quest = null
}
export async function init(questId: string) {
if (!questId) throw new Error('No quest ID provided')
reset()
try {
await setQuest(questId)
} catch (err) {
console.error(err)
throw new LoadQuestError('Failed to load quest', questId)
}
try {
await getActiveSession(questId)
} catch (err) {
console.error(err)
throw new LoadSessionError('Failed to load session', questId)
}
}
function setup() {
socket.on('terminal', (data: string) => {
term.write(data)
})
term.onData((data: string) => {
socket.emit('terminal', data)
})
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
updateGraph(event)
})
socket.on('response', (response: StageResponse) => {
newResponse(response)
})
}
setup()
export default store
+27 -32
View File
@@ -1,35 +1,30 @@
import { defineStore } from 'pinia'
import { trpc } from '../utils/trpc'
import { reactive } from 'vue'
import api from '../utils/api'
interface IProgress {
quest: string
sessions: string[]
completed: boolean
startedAt: Date
finishedAt?: Date
}
export interface UserProfile {
username: string
email: string
progress: Record<string, IProgress>
}
const useUserProfile = defineStore('userProfile', {
state: (): UserProfile => ({
username: '',
email: '',
progress: {},
}),
actions: {
async loadUserProfile() {
const res = await trpc.users.getMyProfile.query()
this.$patch(res)
},
async resetUserProfile() {
this.$reset()
},
},
const store = reactive({
username: '',
email: '',
progress: {},
})
export default useUserProfile
export async function loadUserProfile() {
try {
const res = await api.get('/users/me')
const { username, email, progress } = res.data
store.username = username
store.email = email
store.progress = progress
return store
} catch (err: any) {
console.error(err)
throw new Error(`Failed to load user profile: ${err.message}`)
}
}
export function resetUserProfile() {
store.username = ''
store.email = ''
store.progress = {}
}
export default store
+6 -6
View File
@@ -1,5 +1,6 @@
import type { FileGraph } from '../../packages/file-graph'
import type { IResponse, ITask } from '../../packages/game'
import { FileGraph } from '@linux-odyssey/file-graph'
// eslint-disable-next-line import/no-relative-packages
import { IResponse } from '@linux-odyssey/models'
export interface Task {
id: number
@@ -19,8 +20,7 @@ export interface Session {
status: string
graph: FileGraph
pwd: string
tasks: ITask[]
responses: IResponse[]
token: string
containerName: string
hints: string[][]
tasks: Task[]
responses: IResponse[][]
}
-4
View File
@@ -1,4 +0,0 @@
declare module '*.css' {
const content: string
export default content
}
+3 -8
View File
@@ -1,5 +1,4 @@
import api from './api'
import { i18n } from '../i18n'
import {
TooManyRequestsError,
@@ -33,15 +32,11 @@ function handleRequestError(err: {
const { path, msg } = errors[0]
switch (path) {
case 'username':
throw new ValidationError(
i18n.global.t('authform_error.username_taken')
)
throw new ValidationError('Username is already taken')
case 'email':
throw new ValidationError(i18n.global.t('authform_error.email_taken'))
throw new ValidationError('Email is already taken')
case 'password':
throw new ValidationError(
i18n.global.t('authform_error.invalid_password')
)
throw new ValidationError('Password is too weak')
default:
throw new ValidationError(msg)
}
-40
View File
@@ -1,40 +0,0 @@
import formbricks from '@formbricks/js/website'
import { useSession } from '../store/session'
import useUserProfile from '../store/userProfile'
if (typeof window !== 'undefined') {
formbricks.init({
environmentId: 'clzcdqqn0000kadvtqykdo0pc',
apiHost: 'https://app.formbricks.com',
})
}
export function openQuestSurvey() {
const sessionStore = useSession()
const userStore = useUserProfile()
formbricks.track('quest_completed', {
hiddenFields: {
quest: sessionStore.questId,
session: sessionStore.session?._id,
username: userStore.username,
email: userStore.email,
},
})
}
export function openBugReport() {
const sessionStore = useSession()
const userStore = useUserProfile()
formbricks.track('bug_report', {
hiddenFields: {
quest: sessionStore.questId,
session: sessionStore.session?._id,
username: userStore.username,
email: userStore.email,
},
})
}
export default formbricks
+4 -2
View File
@@ -1,5 +1,5 @@
import { io, Socket } from 'socket.io-client'
import type { Session } from '../types'
import { Session } from '../types'
type SocketCallback = (data: any) => void
@@ -23,9 +23,11 @@ class SocketWrapper {
this.socket.on('connect', () => {
console.log('Socket connected to session', session._id)
resolve()
})
this.socket.once('terminal', () => {
resolve()
})
this.socket.on('connect_error', reject)
this.bindListeners()
})
+28 -63
View File
@@ -1,92 +1,57 @@
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import ReconnectingWebSocket from 'reconnecting-websocket'
import 'xterm/css/xterm.css'
class SocketTerminal {
private term: Terminal
private fitAddon: FitAddon
private socket: ReconnectingWebSocket | null = null
constructor() {
constructor(rows: number, cols: number) {
this.term = new Terminal({
convertEol: true,
scrollback: 1000,
fontSize: 16,
rows, // 行数
cols, // 不指定行数,自动回车后光标从下一行开始
convertEol: true, // 启用时,光标将设置为下一行的开头
// scrollback: 50, //终端中的回滚量
disableStdin: false, // 是否应禁用输入
// cursorStyle: "underline", //光标样式
cursorBlink: true, // 光标闪烁
theme: {
foreground: '#ECECEC',
background: '#000000',
cursor: 'help',
foreground: '#ECECEC', // 字体
background: '#000000', // 背景色
cursor: 'help', // 设置光标
},
fontSize: 18,
fontWeight: 'normal',
})
this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon)
this.term.onData((data) => {
if (this.socket) {
this.socket.send(data)
}
})
this.fitAddon.fit()
}
connect(url: string): Promise<void> {
return new Promise((resolve, reject) => {
console.log('Connecting to terminal', url)
this.term.write('\r\n\x1B[1;32mConnecting to terminal\x1B[0m\r\n')
this.socket = new ReconnectingWebSocket(url, [], {
connectionTimeout: 5000,
maxRetries: 10,
})
this.socket.onopen = () => {
console.log('Connected to terminal')
this.term.reset()
this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n')
resolve()
}
this.socket.onclose = () => {
console.log('Disconnected from terminal')
}
this.socket.onerror = (error) => {
console.error('WebSocket error:', error)
}
this.socket.onmessage = (event) => {
console.log('Received message from terminal')
const data =
typeof event.data === 'string'
? event.data
: new TextDecoder().decode(event.data)
this.term.write(data)
}
})
write(message: string): void {
this.term.write(message)
}
resizeScreen(): void {
try {
this.fitAddon.fit()
} catch (e) {
console.error('Failed to resize terminal:', e)
}
}
mount(reference: HTMLElement): void {
this.term.open(reference)
this.resizeScreen()
}
send(message: string): void {
if (this.socket) {
this.socket.send(message)
}
onData(callback: any): void {
this.term.onData(callback)
}
focus(): void {
this.term.focus()
}
resizeScreen(): void {
this.fitAddon.fit()
}
mount(reference: HTMLElement): void {
this.term.open(reference)
this.term.focus()
this.resizeScreen()
}
reset(): void {
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">
import { useI18n } from 'vue-i18n'
import { isValidUsername } from '../../../packages/utils'
import { isValidUsername } from '@linux-odyssey/utils'
import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import Background from '../components/DynamicBackground.vue'
import { TooManyRequestsError, ValidationError } from '../utils/errors'
import { checkUsername, chooseUsername } from '../utils/auth'
const { t } = useI18n()
async function check({
username,
error,
@@ -19,21 +15,21 @@ async function check({
}) {
if (username) {
if (!isValidUsername(username)) {
error(t('authform_error.invalid_username'))
error('無效的帳號名稱')
return
}
try {
await checkUsername(username)
} catch (err) {
if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests'))
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof ValidationError) {
error(err.message)
return
}
error(t('authform_error.auth_failed'))
error('出了點問題,請再試一次。')
}
}
}
@@ -53,7 +49,7 @@ async function handleSubmit({
success()
} catch (err: any) {
if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests'))
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof ValidationError) {
@@ -67,11 +63,10 @@ async function handleSubmit({
<template>
<div class="w-screen h-screen">
<DynamicBackground class="w-full h-full" />
<Background class="w-full h-full" />
<div
class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
>
<HeaderPart />
<div class="w-fit">
<AuthForm
@onSubmit="handleSubmit"
+33 -51
View File
@@ -1,41 +1,19 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { computed, onMounted, watch } from 'vue'
import { useSession } from '../store/session'
import useUserProfile from '../store/userProfile'
import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
import DescriptionPart from '../components/game/DescriptionPart.vue'
import QuestPart from '../components/game/QuestPart.vue'
import TerminalPart from '../components/game/TerminalPart.vue'
import VisualizationPart from '../components/game/VisualizationPart.vue'
import ControlPalette from '../components/game/ControlPalette.vue'
import CompleteModal from '../components/game/CompleteModal.vue'
import StartButton from '../components/game/StartButton.vue'
import { openQuestSurvey } from '../utils/formbricks'
const { t, locale } = useI18n()
const toast = useToast()
const sessionStore = useSession()
const userStore = useUserProfile()
import sessionStore, { init } from '../store/session'
import GameHeaderPart from '../components/GameHeaderPart.vue'
// import CommandlistPart from '../components/CommandlistPart.vue'
import HintPart from '../components/HintPart.vue'
import QuestPart from '../components/QuestPart.vue'
import TerminalPart from '../components/TerminalPart.vue'
import VisualizationPart from '../components/VisualizationPart.vue'
import ControlPalette from '../components/ControlPalette.vue'
import CompleteModal from '../components/CompleteModal.vue'
import { LoadQuestError, LoadSessionError } from '../utils/errors'
const completed = computed(() => {
return sessionStore.session?.status === 'finished'
})
watch(completed, (newValue, oldValue) => {
if (newValue && !oldValue) {
console.log('finish', userStore)
openQuestSurvey()
}
})
watch(locale, (newLocale) => {
const { session } = sessionStore
if (session && newLocale !== session.locale) {
toast.info(t('game.reset_to_apply_language'))
}
return sessionStore.session.status === 'finished'
})
const props = defineProps({
@@ -45,34 +23,39 @@ const props = defineProps({
},
})
const toast = useToast()
onMounted(async () => {
sessionStore.reset()
sessionStore.setup()
await sessionStore.getActiveSession(props.questId)
if (!sessionStore.session) {
await sessionStore.setQuest(props.questId, locale.value)
try {
await init(props.questId)
} catch (err: any) {
if (err instanceof LoadQuestError) {
toast.error(`無法讀取關卡: ${err.questId},請確認網頁連結。`)
return
}
if (err instanceof LoadSessionError) {
toast.error(`無法建立工作階段: ${err.questId},請重新登入再試一次。`)
return
}
console.error(err)
toast.error(err.message)
}
})
</script>
<template>
<!-- game header -->
<div class="w-full">
<HeaderPart
:title="sessionStore.quest?.title"
:headerComponent="GameHeaderComponents"
/>
<div class="h-[6vh] w-full">
<GameHeaderPart :title="sessionStore.quest?.title" />
</div>
<!-- current status indicator -->
<input
type="hidden"
id="currentStatus"
:value="sessionStore.session?.status"
:value="sessionStore.session.status"
/>
<!-- main -->
<div id="main" class="h-full pt-10 w-full flex p-3 space-x-3">
<div id="main" class="h-[94vh] w-full flex p-3 space-x-3">
<!-- Topic and Command List -->
<div class="bg-bg-secondary h-full w-1/4 rounded-lg">
<section id="quest" class="h-full p-3 overflow-auto">
@@ -84,10 +67,9 @@ onMounted(async () => {
</section> -->
</div>
<!-- Terminal and Hint -->
<div class="bg-bg h-full rounded-lg w-1/2">
<StartButton :questId="$props.questId" />
<div class="bg-bg h-full w-1/2 rounded-lg">
<section id="hint" class="h-3/5">
<DescriptionPart />
<HintPart />
</section>
<hr class="border-border border" />
<section id="terminal" class="h-2/5 overflow-hidden">
+161 -77
View File
@@ -1,30 +1,110 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import ContactButton from '../components/ContactButton.vue'
import DynamicBackground from '../components/DynamicBackground.vue'
import LandingHeaderComponents from '../components/header/LandingHeaderComponents.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import { bugReportUrl, facebookUrl, githubUrl } from '../config'
import { facebookUrl, githubUrl } from '../config'
const { t } = useI18n()
const scrollToSection = (sectionId: string) => {
const section = document.getElementById(sectionId)
if (section) {
const container = document.querySelector('.overflow-y-auto') // Replace '.overflow-y-auto' with the appropriate class or selector for your container
if (container) {
container.scrollTo({
top: section.offsetTop,
behavior: 'smooth', // Add smooth scrolling behavior if desired
})
}
}
}
</script>
<template>
<div class="w-screen h-screen bg-scroll bg-bg">
<DynamicBackground class="w-full h-full" />
<div class="h-screen w-screen absolute top-0 left-0">
<!-- game header -->
<HeaderPart :headerComponent="LandingHeaderComponents" class="z-50" />
<div
class="w-full h-[6vh] px-5 flex flex-row items-center justify-between bg-bg-secondary"
>
<div class="flex flex-row items-center gap-3">
<img src="../img/icon_totem.svg" class="h-8 justify-self-start" />
<button
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap pt-1"
style="font-size: 2vh"
>
Linux Odyssey
</button>
</div>
<div class="flex flex-row items-center gap-3">
<button
id="Intro"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('intro')"
style="font-size: 2vh"
>
Intro
</button>
<button
id="Feature"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('feature')"
style="font-size: 2vh"
>
Feature</button
><button
id="Info"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('about')"
style="font-size: 2vh"
>
About
</button>
<button
id="Contact"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('contact')"
style="font-size: 2vh"
>
Contact Us
</button>
<RouterLink
title="Sign In"
id="SignIn"
to="/register"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
style="font-size: 2vh"
>
Sign In
</RouterLink>
<a
title="Bug Report"
:href="bugReportUrl"
target="_blank"
class="h-5 w-5"
>
<font-awesome-icon
:icon="['fas', 'bug']"
class="text-text-primary h-full w-full"
/>
</a>
</div>
</div>
<!-- main content -->
<main class="w-full h-full overflow-y-auto">
<main class="w-full h-[94vh] overflow-y-auto">
<div class="w-full flex flex-col items-center">
<div
class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10"
>
<h1 class="text-text-primary text-7xl font-extrabold">Linux</h1>
<h1 class="text-text-primary text-7xl font-extrabold font-mono">
Linux
</h1>
<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>
<!-- intro -->
<div class="w-full md:w-2/3 px-5">
<section
@@ -35,7 +115,13 @@ const { t } = useI18n()
class="text-text inline-block bg-bg-primary p-5 rounded-xl opacity-90"
style="font-size: 2.5vh"
>
{{ t('landing.intro') }}
程式設計不僅僅是寫程式碼,還涉及到如何有效地與你的電腦互動。
程式教育卻往往忽略背後「開發環境」的重要性,
使得許多學生在過程中充滿挫折。
「開發環境」是寫程式、執行程式過程所需要的一切工具。
而這些工具需要在終端機輸入指令來使用。
大部分的程式要用來運行的環境,並非 Windows 或 Mac OS,而是 Linux
作業系統。 Linux Odyssey 即是為此設計的線上 Linux 互動教學網站。
</h1>
</section>
<section
@@ -50,27 +136,31 @@ const { t } = useI18n()
class="h-[18vh] row-span-2"
/>
<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"
>
{{ t('landing.feature1') }}
初學者也能立即上手
</h1>
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
{{ t('landing.feature1_desc') }}
我們明白對於初學者來說,設定和使用開發工具可能是一大障礙。
學習者無需安裝任何軟體,僅需通過瀏覽器即可連上一個真實的 Linux
環境。
</h1>
</div>
<div
class="bg-bg-primary p-5 rounded-xl opacity-90 grid grid-cols-3 grid-rows-2 justify-items-center items-center"
>
<h1
class="text-text-primary p-5 font-extrabold col-span-2"
class="text-text-primary p-5 font-extrabold font-mono col-span-2"
style="font-size: 3.5vh"
>
{{ t('landing.feature2') }}
遊戲般的學習體驗
</h1>
<img src="../img/icon_sword.svg" class="h-[18vh] row-span-2" />
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
{{ t('landing.feature2_desc') }}
Linux Odyssey 融合了遊戲的樂趣與學習的實用性。
透過精心設計的遊戲關卡,學習者可以一邊接受挑戰、破解謎題,
一邊掌握 Linux 指令的運用。
</h1>
</div>
<div
@@ -81,13 +171,15 @@ const { t } = useI18n()
class="h-[18vh] row-span-2"
/>
<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"
>
{{ t('landing.feature3') }}
真實的 Linux 操作之旅
</h1>
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
{{ t('landing.feature3_desc') }}
Linux Odyssey 提供的不僅是理論知識,更是真實的操作體驗。
每一個指令都是在真實的 Linux 環境中執行,
讓學習者能夠真正深入軟體開發的世界。
</h1>
</div>
</section>
@@ -107,7 +199,7 @@ const { t } = useI18n()
>
<RouterLink title="StartGame" id="StartGame" to="/register">
<div class="text-text text-xl p-2 w-full text-center">
{{ t('landing.start_journey') }}
準備好鍵盤,跟我們一起開啟你的 Linux 冒險吧!
</div>
</RouterLink>
</section>
@@ -116,78 +208,70 @@ const { t } = useI18n()
class="text-text flex flex-col items-center mt-10 gap-10"
>
<div class="w-full flex flex-col items-center gap-2">
<h1 class="text-text-primary text-3xl font-extrabold">
{{ t('landing.team_intro') }}
<h1 class="text-text-primary text-3xl font-extrabold font-mono">
團隊介紹
</h1>
<p class="text-lg">
{{ t('landing.team_desc') }}
</p>
<p class="text-lg">國立臺北科技大學 智慧自動化工程科 第二屆</p>
<div class="flex flex-row gap-5 justify-center">
<h1 class="font-bold">{{ t('landing.team_member1') }}</h1>
<h1 class="font-bold">{{ t('landing.team_member2') }}</h1>
<h1 class="font-bold">{{ t('landing.team_member3') }}</h1>
<h1 class="font-bold">{{ t('landing.team_member4') }}</h1>
<h1 class="font-bold">Wancat</h1>
<h1 class="font-bold">Neil</h1>
<h1 class="font-bold">A.H.</h1>
<h1 class="font-bold">Ray Gur</h1>
</div>
</div>
<div class="w-full flex flex-col items-center gap-2">
<h1 class="text-text-primary text-3xl font-extrabold">
{{ t('landing.awards') }}
<h1 class="text-text-primary text-3xl font-extrabold font-mono">
得獎肯定
</h1>
<h1 class="text-xl text-center">
{{ t('landing.award_desc') }}
大專校院資訊應用服務創新競賽<br />
鈦坦敏捷開發特別獎 第二名
</h1>
</div>
<div class="container mx-auto">
<div
class="justify-items-center grid grid-cols-1 sm:grid-cols-3 gap-5"
>
<h1
class="text-text-primary text-3xl font-extrabold sm:col-span-3 gap-6"
>
{{ t('landing.player_feedback') }}
<!--可以做成翻頁卡牌一次一個-->
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback1') }}
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback2') }}
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback3') }}
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback4') }}
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback5') }}
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback6') }}
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback7') }}
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback8') }}
</h1>
<h1 class="text-xl text-center">
{{ t('landing.feedback9') }}
</h1>
</div>
</div>
<!-- <h1
class="text-text-primary p-5 font-extrabold font-mono col-span-3 gap-3"
style="font-size: 3.5vh"
>
玩家感想
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"很好玩很像益智遊戲"
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"我從來不知道自由軟體的存在,謝謝你們。"
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"還蠻好玩的,讓程式變好玩"
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"以前不喜歡電腦課現在覺得喜歡一點點"
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"程式真的對新手很友善,讓我學到了很多東西"
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"感覺做得很好很簡明易懂關卡設計很用心"
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動"
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"以前我覺得寫程式很難不過現在覺得很有趣"
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近"
</h1> -->
</section>
</div>
<section
id="contact"
class="text-text mt-10 flex flex-col items-center"
>
<h1 class="text-text-primary font-extrabold text-3xl">
{{ t('landing.contact_us') }}
<h1 class="text-text-primary font-extrabold font-mono text-3xl">
聯絡我們
</h1>
<div
class="flex flex-row items-center justify-around mt-5 gap-5 mb-5"
>
<div class="flex flex-row items-center justify-around mt-5 gap-5">
<ContactButton
:url="facebookUrl"
:icons="['fab', 'facebook']"
+8 -23
View File
@@ -1,14 +1,10 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
import GameHeaderPart from '../components/GameHeaderPart.vue'
import api from '../utils/api'
const { t } = useI18n()
const leaderboard = ref<
{
username: string
@@ -24,7 +20,7 @@ async function getLeaderboard() {
leaderboard.value = res.data
} catch (err: any) {
console.error(err)
toast.error(t('leaderboard.load_leaderboard_failed'))
toast.error('讀取排行榜失敗')
}
}
@@ -39,34 +35,23 @@ onMounted(async () => {
<div
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
>
<div class="w-full">
<HeaderPart
:title="t('leaderboard.title')"
:headerComponent="GameHeaderComponents"
/>
<div class="h-[6vh] w-full">
<GameHeaderPart title="排行榜" />
</div>
<div class="w-fit h-[94vh] m-3">
<div
class="h-full bg-bg border-8 border-bg-primary flex flex-col items-center rounded-3xl p-10"
>
<h1 class="text-text-primary text-3xl mb-5">
{{ t('leaderboard.title') }}
</h1>
<h1 class="text-text-primary text-3xl mb-5">排行榜</h1>
<div class="max-h-300 overflow-auto">
<table class="w-full text-sm text-left text-text">
<thead
class="text-xs text-text-secondary uppercase bg-bg-secondary"
>
<tr>
<th scope="col" class="px-6 py-3">
{{ t('leaderboard.username') }}
</th>
<th scope="col" class="px-6 py-3">
{{ t('leaderboard.score') }}
</th>
<th scope="col" class="px-6 py-3">
{{ t('leaderboard.completed_quests') }}
</th>
<th scope="col" class="px-6 py-3">使用者名稱</th>
<th scope="col" class="px-6 py-3">分數</th>
<th scope="col" class="px-6 py-3">完成關卡</th>
</tr>
</thead>
<tbody>
+6 -11
View File
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import Background from '../components/DynamicBackground.vue'
import {
TooManyRequestsError,
@@ -11,8 +9,6 @@ import {
} from '../utils/errors'
import { login } from '../utils/auth'
const { t } = useI18n()
const handleLogin = async ({
username,
password,
@@ -30,30 +26,29 @@ const handleLogin = async ({
if (isSuccess) success()
} catch (err) {
if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests'))
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof UnauthorizedError) {
error(t('authform_error.unauthorized'))
error('錯誤的帳號名稱或密碼。')
return
}
if (err instanceof ValidationError) {
error(err.message)
error('無效的帳號名稱或密碼。')
return
}
console.error(err)
error(t('authform_error.auth_failed'))
error('出了點問題。')
}
}
</script>
<template>
<div class="w-screen h-screen">
<DynamicBackground class="w-full h-full" />
<Background class="w-full h-full" />
<div
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
>
<HeaderPart />
<div class="w-fit">
<AuthForm @onSubmit="handleLogin" type="login" />
</div>
+4 -12
View File
@@ -1,21 +1,13 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import QuestMap from '../components/QuestMap.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
const { t } = useI18n()
import GameHeaderPart from '../components/GameHeaderPart.vue'
</script>
<template>
<div class="w-fit">
<HeaderPart
:title="t('quest_map.title')"
:headerComponent="GameHeaderComponents"
class="z-50"
/>
<div class="h-[6vh] w-full">
<GameHeaderPart title="關卡地圖" />
</div>
<div class="flex flex-col justify-center items-center w-full h-full pt-5">
<div class="flex flex-col justify-center items-center w-full h-[94vh]">
<QuestMap />
</div>
</template>
+14 -18
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import validator from 'validator'
import { isValidUsername } from '../../../packages/utils'
import { passwordPolicy } from '../../../packages/constants'
import { isValidUsername } from '@linux-odyssey/utils'
import { passwordPolicy } from '@linux-odyssey/constants'
import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import Background from '../components/DynamicBackground.vue'
import { checkUsername, register } from '../utils/auth'
import {
UnauthorizedError,
@@ -13,8 +12,6 @@ import {
ValidationError,
} from '../utils/errors'
const { t } = useI18n()
function handleRegister({
username,
email,
@@ -33,11 +30,11 @@ function handleRegister({
.then(success)
.catch((err) => {
if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests'))
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof UnauthorizedError) {
error(t('authform_error.unauthorized'))
error('無效的帳號名稱或密碼。')
return
}
if (err instanceof ValidationError) {
@@ -45,7 +42,7 @@ function handleRegister({
return
}
console.error(err)
error(t('authform_error.auth_failed'))
error('出了點問題。')
})
}
@@ -62,15 +59,15 @@ async function check({
error: (msg: string) => void
}) {
if (username && !isValidUsername(username)) {
error(t('authform_error.invalid_username'))
error('無效的帳號名稱')
return
}
if (email && !validator.isEmail(email)) {
error(t('authform_error.invalid_email'))
error('無效的電子郵件')
return
}
if (password && !validator.isStrongPassword(password, passwordPolicy)) {
error(t('authform_error.invalid_password'))
error('密碼必須超過8個字元,至少包含一個數字、一個大寫及一個小寫。')
return
}
try {
@@ -81,23 +78,22 @@ async function check({
return
}
if (err instanceof TooManyRequestsError) {
error(t('authform_error.too_many_requests'))
error('太多請求,兩分鐘後再試一次。')
return
}
console.error(err)
error(t('authform_error.auth_failed'))
error('出了點問題。')
}
}
</script>
<template>
<div class="w-screen h-screen">
<DynamicBackground class="w-full h-full" />
<Background class="w-full h-full" />
<div
class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
>
<HeaderPart />
<div class="w-fit mt-8">
<div class="w-fit">
<AuthForm
@onSubmit="handleRegister"
@onChange="check"
-3
View File
@@ -24,9 +24,6 @@ export default {
DEFAULT: '#FF0000',
},
},
backgroundImage: {
catelogbg: "url('/catelogbg.svg')",
},
},
},
plugins: [],
-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": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "../packages/utils" },
{ "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"]
}
-16
View File
@@ -1,27 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@game': path.resolve(__dirname, '../packages/game'),
'@models': path.resolve(__dirname, '../packages/models'),
'@utils': path.resolve(__dirname, '../packages/utils'),
'@constants': path.resolve(__dirname, '../packages/constants'),
'@file-graph': path.resolve(__dirname, '../packages/file-graph'),
},
},
server: {
host: '0.0.0.0',
proxy: {
'/trpc': {
target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false,
xfwd: true,
},
'/api': {
target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false,
+18 -1
View File
@@ -1,10 +1,27 @@
server {
listen 80;
error_log /var/log/nginx/error.log debug;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api-docs/ {
proxy_pass http://backend:3000;
}
location /socket.io/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
+95
View File
@@ -0,0 +1,95 @@
version: '3'
services:
app:
depends_on:
- backend
build:
context: .
dockerfile: app/Dockerfile
target: dev
ports:
- 5173:5173
networks:
- internal
volumes:
- ./app:/app/app
- ./packages:/app/packages
backend:
depends_on:
- db
build:
context: .
dockerfile: server/Dockerfile
target: dev
networks:
- internal
- players
ports:
- '3000:3000'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./server/src:/app/server/src
- ./server/dist:/app/server/dist
- ./server/swagger.yaml:/app/server/swagger.yaml
- ./packages:/app/packages
- ./quests:/app/quests
- ./logs:/app/logs
environment:
- BASE_URL=http://localhost:5173
- MONGO_URL=mongodb://db:27017/odyssey-test
- DOCKER_NETWORK=linux-odyssey-players
- SECRET_KEY=b1d2e917-17b9-4fce-9055-082dee6ec5d6 # Only for testing
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
- SURVEY_URL=${SURVEY_URL}
- BUG_REPORT_URL=${BUG_REPORT_URL}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- TESTING=true
- TESTING_USERNAME=alex
- TESTING_PASSWORD=Alex1234
- HOST_PWD=${PWD}
- MOUNT_QUEST=${MOUNT_QUEST}
analytics:
build:
context: .
dockerfile: packages/analytics/Dockerfile
target: dev
depends_on:
- db
networks:
- internal
ports:
- '3001:3001'
environment:
- MONGO_URL=mongodb://db:27017/odyssey-test
- ADMIN_USERNAME=alex
- ADMIN_PASSWORD=Alex1234
volumes:
- ./packages/analytics:/app/packages/analytics
- ./packages/models:/app/packages/models
db:
image: mongo
restart: always
networks:
- internal
ports:
- '27017:27017'
volumes:
- ./data:/data/db
base:
image: linuxodyssey/quest-base
build:
context: .
dockerfile: ./quests/Dockerfile
networks:
internal:
players:
name: linux-odyssey-players
internal: true
+32 -53
View File
@@ -1,23 +1,7 @@
version: '3'
services:
traefik:
image: traefik:v3.6.9
ports:
- 8000:8000
- 8080:8080
networks:
- players
- internal
command:
- '--api.insecure=true'
- '--providers.docker=true'
- '--providers.docker.exposedbydefault=false'
- '--entrypoints.web.address=:8000'
- '--entrypoints.web.forwardedHeaders.insecure=true' # trust all X-Forwarded headers
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
app:
image: linuxodyssey/frontend:${IMAGE_TAG:-latest}
image: linuxodyssey/frontend
depends_on:
- backend
restart: always
@@ -26,16 +10,12 @@ services:
dockerfile: app/Dockerfile
target: prod
networks:
- internal
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.app.entrypoints=web'
- 'traefik.http.routers.app.rule=PathPrefix(`/`)'
- 'traefik.http.routers.app.service=app'
- 'traefik.http.services.app.loadbalancer.server.port=80'
internal:
ports:
- 8080:80
backend:
image: linuxodyssey/backend:${IMAGE_TAG:-latest}
image: linuxodyssey/backend
depends_on:
- db
restart: always
@@ -49,35 +29,21 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./logs:/app/logs
- ./config:/app/config
- ./quests:/app/quests:ro
env_file:
- .env.prod
- .env
environment:
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
- DOCKER_NETWORK=linux-odyssey-players
- SECRET_KEY=${SECRET_KEY}
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
- BACKEND_URL=http://backend:3000
- 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'
- BASE_URL=${BASE_URL}
analytics:
image: linuxodyssey/analytics:${IMAGE_TAG:-latest}
image: linuxodyssey/analytics
build:
context: .
dockerfile: services/analytics/Dockerfile
dockerfile: packages/analytics/Dockerfile
target: prod
restart: always
depends_on:
@@ -86,11 +52,13 @@ services:
- internal
ports:
- '3001:3001'
env_file:
- .env.prod
environment:
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
db:
image: mongo:6
image: mongo
restart: always
networks:
- internal
@@ -99,11 +67,22 @@ services:
volumes:
- mongo:/data/db
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
env_file:
- .env.prod
environment:
- BASE_URL=${BASE_URL}
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
- MONGO_USER=${MONGO_USER}
- MONGO_PASS=${MONGO_PASSWORD}
- MONGO_DB=${MONGO_DB}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- SURVEY_URL=${SURVEY_URL}
- BUG_REPORT_URL=${BUG_REPORT_URL}
base:
image: linuxodyssey/quest-base:${IMAGE_TAG:-latest}
image: linuxodyssey/quest-base
build:
context: .
dockerfile: ./quests/Dockerfile
-54
View File
@@ -1,54 +0,0 @@
version: '3'
services:
traefik:
image: traefik:v3.6.9
networks:
- players-dev
command:
- '--api.insecure=true'
- '--providers.docker=true'
- '--providers.docker.exposedbydefault=false'
- '--providers.file.directory=/etc/traefik/conf'
- '--providers.file.watch=true'
- '--entrypoints.web.address=:8000'
ports:
- '8000:8000'
- '8080:8080'
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/conf:/etc/traefik/conf
db:
image: mongo:6
restart: always
networks:
- internal
ports:
- '27017:27017'
volumes:
- ./data:/data/db
base:
image: linuxodyssey/quest-base
networks:
- players-dev
build:
context: .
dockerfile: ./quests/Dockerfile
extra_hosts:
- 'host.docker.internal:host-gateway'
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.base.rule=PathPrefix(`/terminal/base`)'
- 'traefik.http.middlewares.base-stripprefix.stripprefix.prefixes=/terminal/base'
- 'traefik.http.routers.base.middlewares=base-stripprefix'
- 'traefik.http.routers.base.entrypoints=web'
- 'traefik.http.services.base.loadbalancer.server.port=9090'
networks:
internal:
players-dev:
name: linux-odyssey-players-dev
internal: false
+3 -4
View File
@@ -32,16 +32,15 @@ down() {
dump() {
# Define service name and dump directory name
SERVICE_NAME="db"
COMPOSE_FILE="docker-compose.prod.yml"
DUMP_NAME="mongodump-$(date --iso).archive"
CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q $SERVICE_NAME)
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
# Fetch credentials from Docker environment variables
MONGO_URL=$(docker compose -f $COMPOSE_FILE exec -T backend printenv MONGO_URL)
MONGO_URL=$(docker compose exec -T backend printenv MONGO_URL)
echo mongo uri: $MONGO_URL
# Dump MongoDB data using credentials
docker compose -f $COMPOSE_FILE exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME --uri=$MONGO_URL" &&
docker compose exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME -d odyssey-test" &&
# Copy the dump from the container to the host
# Note: You will need to know the exact container name for this step
-198
View File
@@ -1,198 +0,0 @@
# Windows Environment Setup
## 1. Install WSL and Ubuntu
Refer to Microsoft’s official [WSL installation guide](https://learn.microsoft.com/en-us/windows/wsl/install).
Open PowerShell as Administrator and run:
```powershell
wsl --install
```
Restart your computer after the installation finishes.
Open Ubuntu from the Start menu. The first time Ubuntu starts, it will ask you to create a Linux username and password.
Verify that Ubuntu is using WSL 2 by running this command in PowerShell:
```powershell
wsl --update
wsl --list --verbose
```
Ubuntu should show `2` in the `VERSION` column.
If it is using WSL 1, run:
```powershell
wsl --set-version Ubuntu 2
```
## 2. Install Docker Desktop
Install [Docker Desktop for Windows](https://docs.docker.com/desktop/setup/install/windows-install/).
Start Docker Desktop after installation.
Docker Desktop normally uses the WSL 2 engine automatically. If the `Use the WSL 2 based engine` option appears under `Settings > General`, ensure it is enabled.
Go to:
```text
Settings > Resources > WSL Integration
```
Enable integration with your Ubuntu distribution, then select **Apply & restart**.
If the WSL Integration section is unavailable, ensure Docker Desktop is using Linux containers.
> [!IMPORTANT]
> Do not separately install Docker Engine inside Ubuntu when using Docker Desktop. Having both installations may cause conflicts.
Open the Ubuntu terminal and verify that Docker is available:
```bash
docker --version
docker compose version
docker run --rm hello-world
```
## 3. Set Up the Ubuntu Environment
Run the following commands in the Ubuntu terminal.
### Update the Package List
```bash
sudo apt update
```
### Install the Required Tools
```bash
sudo apt install -y git curl ca-certificates build-essential
```
### Install Node.js 20
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
```
### Install Yarn 1.22
```bash
sudo npm install -g yarn@1.22.22
```
Verify the installations:
```bash
git --version
node --version
npm --version
yarn --version
```
The Node.js version should be `20.x` or later, and the Yarn version should be `1.22.x`.
## 4. Clone the Project
> [!WARNING]
> Clone the repository into the WSL Linux filesystem, such as `~/projects`. Do not place it on a Windows-mounted drive such as `/mnt/c/...` or `/mnt/d/...`. The project uses a bind mount for MongoDB data, and Windows-mounted directories may cause permission and filesystem compatibility problems.
Create and enter a project directory:
```bash
mkdir -p <path-to-projects-directory>
cd <path-to-projects-directory>
```
Clone the repository:
```bash
git clone https://github.com/linux-odyssey/linux-odyssey.git
cd linux-odyssey
```
### Optional: Open the Project in VS Code
Install [Visual Studio Code](https://code.visualstudio.com/) and the **WSL** extension first. Then run:
```bash
code .
```
## 5. Install Project Dependencies
From the project root, run:
```bash
yarn install
```
Warnings during installation may be harmless. The installation succeeded if Yarn finishes with a `Done` message and does not display `error Command failed`.
If a `gyp` error appears, confirm that it belongs to an optional dependency before ignoring it.
## 6. Create the Environment File
Copy the sample environment file:
```bash
cp .env.sample .env
```
Confirm that the file exists:
```bash
ls -la .env
```
The default values are suitable for local development. Do not use the sample secret key in a production environment.
## 7. Start the Docker Services
Ensure Docker Desktop is running, then execute:
```bash
docker compose pull
docker compose build
docker compose up -d
```
Check that the containers are running:
```bash
docker compose ps
```
To stop the Docker services later:
```bash
docker compose down
```
## 8. Run the Project
Start the frontend and backend development servers:
```bash
yarn 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
yarn build
```
+11 -15
View File
@@ -9,7 +9,6 @@
"app",
"server",
"packages/*",
"services/*",
"tests"
],
"scripts": {
@@ -17,6 +16,7 @@
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
"server": "yarn workspace server dev",
"cli": "yarn workspace client start",
"lint": "eslint .",
"format": "prettier --ignore-path .prettierignore --write app server",
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
@@ -28,36 +28,32 @@
"build:quests": "yarn workspace server build:quests"
},
"devDependencies": {
"@babel/core": "^7.29.6",
"@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",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vue/eslint-config-prettier": "^7.1.0",
"babel-jest": "^29.7.0",
"concurrently": "^8.2.2",
"eslint": "^8.56.0",
"eslint": "^8.44.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-plugin-cypress": "^2.15.1",
"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",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"vue-eslint-parser": "^9.4.2",
"zod-to-json-schema": "^3.24.5"
"typescript": "^5.3.3"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"resolutions": {
"**/@mapbox/node-pre-gyp/tar": "^7.5.19",
"**/@cypress/request/form-data": "^2.5.4"
"dependencies": {
"typescript-eslint": "^7.4.0"
}
}
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"
}
@@ -3,28 +3,30 @@
"version": "1.0.0",
"main": "src/index.js",
"private": true,
"type": "module",
"license": "GPL",
"scripts": {
"start": "node dist/services/analytics/src/index.js",
"build": "tsc",
"type-check": "tsc --noEmit",
"dev": "tsx watch src/index.ts"
"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.20.0",
"express": "^4.19.2",
"express-basic-auth": "^1.2.1",
"express-handlebars": "^7.1.2",
"mongoose": "^8.24.1",
"zod": "^3.24.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",
"tsx": "^4.19.3",
"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

@@ -21,16 +21,14 @@ interface Config {
}
const config: Config = {
host: process.env.ADMIN_HOST || 'localhost',
port: process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001,
host: process.env.HOST || 'localhost',
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3001,
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
username: getOrFailed('ADMIN_USERNAME'),
password: getOrFailed('ADMIN_PASSWORD'),
baseUrl:
process.env.ADMIN_BASE_URL ||
`http://${process.env.ADMIN_HOST || 'localhost'}:${
process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001
}`,
process.env.BASE_URL ||
`http://${process.env.HOST || 'localhost'}:${process.env.PORT ? parseInt(process.env.PORT, 10) : 3001}`,
}
export default config
@@ -1,6 +1,6 @@
import type { Request, Response } from 'express'
import Pagination from '../models/pagination'
import { errorCommands } from '../models/commands'
import Pagination from '../models/pagination.js'
import { errorCommands } from '../models/commands.js'
export async function commandListController(
req: Request,
@@ -1,6 +1,6 @@
import { Request, Response } from 'express'
import { userCount } from '../models/users'
import { questList } from '../models/quests'
import { userCount } from '../models/users.js'
import { questList } from '../models/quests.js'
export async function homeController(req: Request, res: Response) {
const quests = await questList()
@@ -1,5 +1,5 @@
import { Request, Response } from 'express'
import { questList } from '../models/quests'
import { questList } from '../models/quests.js'
export async function questListController(req: Request, res: Response) {
const quests = await questList()
@@ -1,6 +1,6 @@
import { Request, Response } from 'express'
import Pagination from '../models/pagination'
import { sessionList, sessionDetail } from '../models/sessions'
import Pagination from '../models/pagination.js'
import { sessionList, sessionDetail } from '../models/sessions.js'
export async function sessionListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string }
@@ -1,7 +1,7 @@
import { Request, Response } from 'express'
import mongoose from 'mongoose'
import Pagination from '../models/pagination'
import { userList, userDetail, idToUser } from '../models/users'
import Pagination from '../models/pagination.js'
import { userList, userDetail, idToUser } from '../models/users.js'
export async function userListController(req: Request, res: Response) {
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 { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth'
import { connectDB } from '../../../packages/models'
import config from './config'
import router from './router'
import config from './config.js'
import router from './router.js'
async function main() {
await connectDB(config.db)
@@ -1,7 +1,6 @@
import { Types } from 'mongoose'
export interface User {
_id: string
username: string
}
export interface SessionDocument {
@@ -9,10 +8,8 @@ export interface SessionDocument {
user: User
quest: string
status: string
locale: string
createdAt: Date
finishedAt: Date
lastActivityAt: Date
}
export interface CommandObject {
command: string
@@ -24,17 +21,24 @@ export interface CommandObject {
}
export interface SessionObject {
_id: Types.ObjectId
user: User
user: string
quest: string
locale: string
status: string
createdAt: string
finishedAt: string
lastActivityAt: string
usedTime: string
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[]
}
export interface IUser extends Document {
@@ -1,5 +1,5 @@
import { Command } from '../../../../packages/models'
import Pagination from './pagination'
import { Command } from '@linux-odyssey/models'
import Pagination from './pagination.js'
// eslint-disable-next-line import/prefer-default-export
export async function errorCommands(pagination: Pagination) {
@@ -1,5 +1,5 @@
/* 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'
export const DESC: string = 'desc'
@@ -1,14 +1,8 @@
import { UserProfile } from '../../../../packages/models'
/* eslint-disable object-shorthand */
/* eslint-disable func-names */
import { UserProfile } from '@linux-odyssey/models'
export interface QuestProgress {
_id: string
started: number
completed: number
completedRate: number
failed: number
}
export function questList(): Promise<QuestProgress[]> {
export function questList() {
return UserProfile.aggregate([
{
$project: {
@@ -1,5 +1,5 @@
import { Command, Session } from '../../../../packages/models'
import Pagination from './pagination'
import { Command, Session } from '@linux-odyssey/models'
import Pagination from './pagination.js'
import {
CommandObject,
SessionDetail,
@@ -28,6 +28,14 @@ export async function sessionList(
},
},
{ $unwind: '$user' },
{
$lookup: {
from: 'commands',
localField: '_id',
foreignField: 'session',
as: 'commands',
},
},
pagination.sort('_id'),
pagination.limit(),
])
@@ -37,17 +45,16 @@ export async function sessionList(
_id,
user,
quest,
locale,
status,
createdAt,
finishedAt,
commands,
lastActivityAt,
} = session
return {
_id,
user: { _id: user._id, username: user.username },
user: user.username, // Assuming `user` is always populated.
quest,
locale: locale ?? 'zh-TW',
status,
createdAt: createdAt?.toLocaleString(),
finishedAt: finishedAt?.toLocaleString(),
@@ -56,7 +63,8 @@ export async function sessionList(
lastActivityAt && createdAt
? formatTime(lastActivityAt.getTime() - createdAt.getTime())
: '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(),
})
)
const {
_id,
user,
quest,
locale,
status,
createdAt,
finishedAt,
lastActivityAt,
} = session
const { _id, user, quest, status, createdAt, finishedAt } = session
return {
_id,
user: { _id: user._id, username: user.username },
user: user.username,
quest,
locale: locale ?? 'zh-TW',
status,
createdAt: createdAt?.toLocaleString(),
finishedAt: finishedAt?.toLocaleString(),
lastActivityAt: lastActivityAt?.toLocaleDateString(),
commandCount: commands.length,
usedTime: finishedAt
? formatTime(finishedAt.getTime() - createdAt.getTime())
: '',
@@ -1,7 +1,7 @@
import { HydratedDocument, Types } from 'mongoose'
import { Session, User, IUser, ISession } from '../../../../packages/models'
import Pagination from './pagination'
import { UserSessionDetail } from '../interface'
import { Session, User } from '@linux-odyssey/models'
import { Types } from 'mongoose'
import Pagination from './pagination.js'
import { IUser, UserSessionDetail } from '../interface.js'
function loginMethods(user: IUser): string[] {
const methods: string[] = []
@@ -23,11 +23,7 @@ export function userCount() {
}
export async function userList(pagination: Pagination) {
const users: {
userData: HydratedDocument<IUser>
count: number
lastActivityAt: Date
}[] = await Session.aggregate([
const users = await Session.aggregate([
pagination.match('user'),
{
$group: {
@@ -66,7 +62,7 @@ export async function userList(pagination: Pagination) {
export async function userDetail(
id: Types.ObjectId
): Promise<UserSessionDetail[]> {
const sessions: HydratedDocument<ISession>[] = await Session.find({
const sessions = await Session.find({
user: id,
})
return sessions.map((session): UserSessionDetail => {
@@ -1,13 +1,16 @@
import { Router } from 'express'
import { homeController } from './controllers/index'
import { userListController, userDetailController } from './controllers/users'
import { questListController } from './controllers/quests'
import { commandListController } from './controllers/commands'
import { homeController } from './controllers/index.js'
import {
userListController,
userDetailController,
} from './controllers/users.js'
import { questListController } from './controllers/quests.js'
import { commandListController } from './controllers/commands.js'
import {
sessionDetailController,
sessionListController,
} from './controllers/sessions'
} from './controllers/sessions.js'
const router = Router()
@@ -1,9 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "./dist"
},
"include": ["src", "../models"],
"include": ["src"],
"references": [{ "path": "../models" }],
"ts-node": {
"swc": true
}
Loaded 100 of 420 files, more files were not shown because too many files have changed in this diff. Show more