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
311 changed files with 6678 additions and 10963 deletions

No files matched your search

-1
View File
@@ -2,4 +2,3 @@
.git .git
**/dist **/dist
**/tsconfig.tsbuildinfo **/tsconfig.tsbuildinfo
data
-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: { settings: {
'import/resolver': { 'import/resolver': {
node: { node: {
extensions: ['.js', '.ts'], extensions: ['.js', '.jsx', '.ts', '.tsx'],
moduleDirectory: ['node_modules', 'packages', 'server/src'],
},
typescript: {
alwaysTryTypes: true,
}, },
typescript: {},
}, },
vue: { vue: {
version: '3.0', // Explicitly specify Vue 3 version: '3.0', // Explicitly specify Vue 3
@@ -52,7 +49,7 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', 'error',
{ {
args: 'after-used', args: 'after-used',
argsIgnorePattern: '^_', argsIgnorePattern: '^_',
@@ -66,44 +63,21 @@ module.exports = {
// Allow missing file extensions // Allow missing file extensions
'import/extensions': [ 'import/extensions': [
'off', 'warn',
'ignorePackages', 'ignorePackages',
{ {
js: 'never', js: 'off',
jsx: 'never', jsx: 'never',
ts: 'never', ts: 'never',
tsx: 'never', tsx: 'never',
}, },
], ],
'no-unused-vars': 'warn',
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': 'error',
'no-empty-function': 'off',
'@typescript-eslint/no-empty-function': 'error',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-restricted-imports': ['error', '../*'],
'import/no-relative-packages': 'off',
'no-await-in-loop': 'off',
}, },
overrides: [ overrides: [
{ {
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
rules: { rules: {
'import/extensions': 'off', 'import/extensions': 'off', // Turn off the rule for test files
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['server/**/*.ts'],
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['**/packages/**/*.ts'],
rules: {
'no-restricted-imports': 'off',
'import/no-extraneous-dependencies': 'off', 'import/no-extraneous-dependencies': 'off',
}, },
}, },
+3 -4
View File
@@ -18,9 +18,9 @@ on:
secrets: secrets:
DOCKERHUB_USERNAME: DOCKERHUB_USERNAME:
required: false required: true
DOCKERHUB_TOKEN: DOCKERHUB_TOKEN:
required: false required: true
jobs: jobs:
build-and-push: build-and-push:
@@ -42,7 +42,6 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Login to Docker Hub - name: Login to Docker Hub
if: always()
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -54,5 +53,5 @@ jobs:
context: . context: .
file: ${{ inputs.dockerfilePath }} file: ${{ inputs.dockerfilePath }}
target: ${{ inputs.target }} target: ${{ inputs.target }}
push: ${{ secrets.DOCKERHUB_USERNAME != '' }} push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: ./.github/workflows/build-docker-image.yml uses: ./.github/workflows/build-docker-image.yml
with: with:
imageName: 'linuxodyssey/analytics' imageName: 'linuxodyssey/analytics'
dockerfilePath: './services/analytics/Dockerfile' dockerfilePath: './packages/analytics/Dockerfile'
target: prod target: prod
secrets: inherit secrets: inherit
+29
View File
@@ -2,6 +2,15 @@ name: CI Checks
on: on:
workflow_call: workflow_call:
secrets:
GOOGLE_CLIENT_ID:
required: true
GOOGLE_CLIENT_SECRET:
required: true
GH_CLIENT_ID:
required: true
GH_CLIENT_SECRET:
required: true
jobs: jobs:
ci: ci:
@@ -26,3 +35,23 @@ jobs:
- name: Test - name: Test
run: yarn 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
-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: jobs:
ci-checks: ci-checks:
uses: ./.github/workflows/ci-checks.yml uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build: build:
needs: ci-checks needs: ci-checks
+1
View File
@@ -10,6 +10,7 @@ concurrency:
jobs: jobs:
ci-checks: ci-checks:
uses: ./.github/workflows/ci-checks.yml uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build: build:
uses: ./.github/workflows/build.yml uses: ./.github/workflows/build.yml
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs: jobs:
ci-checks: ci-checks:
uses: ./.github/workflows/ci-checks.yml uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build: build:
needs: ci-checks needs: ci-checks
-8
View File
@@ -1,9 +1,3 @@
config
.quest
tmp
services/terminal-service/terminal-service
bin
# Logs # Logs
logs logs
*.log *.log
@@ -91,7 +85,6 @@ built
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local .env.local
.env.prod
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
@@ -145,4 +138,3 @@ dist
#screenshots #screenshots
tests/cypress/screenshots tests/cypress/screenshots
services/cmd-hook/cmd-hook
+1 -7
View File
@@ -1,11 +1,5 @@
{ {
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true
"yaml.schemas": {
"packages/game/quest.json": "quests/**/game.yml"
},
"[go]": {
"editor.defaultFormatter": "golang.go"
}
} }
+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) [![Production](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
[![Staging](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml) [![Staging](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml)
## TLDR
yarn install
cp .env.sample .env
docker compose up -d
yarn dev
## Requirements ## Requirements
- NodeJS v20+ - NodeJS
- Docker - Docker
- Yarn: v1.22 - Yarn
## Known Issues
- MongoDB requires x86 architecture to run for v5+ versions. You can track [this issue](https://github.com/linux-odyssey/linux-odyssey/issues/166) for updates.
Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for setup instructions.
## Installation ## Installation
@@ -28,61 +15,30 @@ Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for
cd linux-odyssey cd linux-odyssey
yarn install yarn install
## Project Structures ## Development
1. **app**: frontend Run mongoDB first:
2. **server**: backend
3. **quests**: tutorials scripts for each quest
4. **packages**: other packages
1. **analytics**: admin dashboard to analyze collected data
2. **constants**: some shared config values for app and server
3. **container**: the script run in container to capture users' command
4. **file-graph**: the algorithm to construct and modify file graph, used in both app and server
5. **models**: definitions of MondoDB collections (data schema)
6. **utils**: other utility code shared between packages
7. **game (in the future)**: core logic of game playing, will be extracted from server
## Configuration 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. yarn dev
2. **docker-compose.prod.yml**: The one used for production deployment. It has a Nginx service, a backend, a database, an analytics service, and creates isolated networks for player containers.
3. **docker-compose.testing.yml**: Similar to production config, but sets up an one-time environment for end-to-end testing.
### Common Development Setup 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: 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`.
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.
quests/ quests/
├── Dockerfile # Base image ├── Dockerfile # Base image
├── entrypoint.sh # Entrypoint for the container
├── discover ├── discover
│ └── game.yml │ └── game.yml
├── helloworld ├── helloworld
@@ -98,56 +54,14 @@ If you want to include additional files, put them in a `home` folder. The files
└── home └── home
└── ancient_scroll.txt └── 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: To build all quests:
yarn build:quests yarn build:quests
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests. The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
### Optional: Mounting Host Directory
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 ## Testing
Unit tests:
yarn test:unit
Run Cypress locally, using the current development containers: Run Cypress locally, using the current development containers:
docker compose up -d docker compose up -d
@@ -164,37 +78,76 @@ Run Cypress in container, create brand-new containers along with it:
# tearing off the containers # tearing off the containers
./docker-scripts.sh down ./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 build
docker compose -f docker-compose.prod.yml push docker compose -f docker-compose.prod.yml push
# Server side # Server side
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
yarn build:quests
docker compose -f docker-compose.prod.yml up -d 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: 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 # Base stage
FROM node:20-alpine AS build FROM node:20-alpine as base
WORKDIR /app 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 app/package.json ./app/package.json
COPY server/package.json ./server/package.json
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
COPY tsconfig.json ./tsconfig.json # Dev stage
COPY server ./server FROM base as dev
COPY packages ./packages WORKDIR /app/app
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
# Build stage
FROM base as build
COPY packages/file-graph ./packages/file-graph
COPY packages/utils ./packages/utils
COPY packages/constants ./packages/constants
COPY packages/models ./packages/models
COPY app ./app COPY app ./app
RUN yarn workspace app build RUN yarn workspace app build
# Production stage # Production stage
FROM nginx:stable-alpine AS prod FROM nginx:stable-alpine as prod
COPY --from=build /app/app/dist /usr/share/nginx/html COPY --from=build /app/app/dist /usr/share/nginx/html
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
+12 -13
View File
@@ -5,24 +5,24 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:docker": "API_TARGET=http://backend:3000 vite", "dev:docker": "API_TARGET=http://backend:3000 vite",
"build": "vue-tsc && vite build", "build": "vue-tsc -b && vite build",
"type-check": "vue-tsc",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@formbricks/js": "^2.1.1",
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.0", "@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3", "@fortawesome/vue-fontawesome": "^3.0.3",
"@trpc/client": "^11.1.0", "@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"@linux-odyssey/models": "*",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"axios": "^1.8.2", "axios": "^1.6.0",
"dompurify": "^3.2.4", "dompurify": "^3.0.6",
"echarts": "^5.4.3",
"marked": "^10.0.0", "marked": "^10.0.0",
"pinia": "^2.1.7",
"reconnecting-websocket": "^4.4.0",
"socket.io-client": "^4.7.1", "socket.io-client": "^4.7.1",
"validator": "^13.11.0", "validator": "^13.11.0",
"vue": "^3.3.4", "vue": "^3.3.4",
@@ -35,14 +35,13 @@
}, },
"devDependencies": { "devDependencies": {
"@types/validator": "^13.11.9", "@types/validator": "^13.11.9",
"@vitejs/plugin-vue": "^5.2.2", "@vitejs/plugin-vue": "^4.5.2",
"@vue/compiler-sfc": "^3.3.13", "@vue/compiler-sfc": "^3.3.13",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "~5.7.2", "typescript": "^5.3.3",
"vite": "^6.3.1", "vite": "^4.5.3",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.0.10"
} }
} }
-17
View File
@@ -83,16 +83,6 @@ const handleChange = () => {
errorMessage.value = 'Something went wrong' 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> </script>
<template> <template>
<!-- login page --> <!-- login page -->
@@ -229,13 +219,6 @@ const registerGuest = async () => {
</RouterLink></span </RouterLink></span
> >
</p> </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()"
>
以訪客身分繼續
</button>
</form> </form>
</div> </div>
</template> </template>
@@ -2,6 +2,6 @@
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" /> <font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
<h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1> <h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1>
<div id="cheatsheets"> <div id="cheatsheets">
<p class="text-text-secondary">-- --指令列表-- --</p> <p class="text-text-secondary">--指令列表--</p>
</div> </div>
</template> </template>
@@ -4,16 +4,14 @@ import { useToast } from 'vue-toastification'
import { ref } from 'vue' import { ref } from 'vue'
import { Vue3Lottie } from 'vue3-lottie' import { Vue3Lottie } from 'vue3-lottie'
// import { surveyUrl } from '../config' // import { surveyUrl } from '../config'
import CompleteAnimation from '../../img/complete-animation.json' import CompleteAnimation from '../img/complete-animation.json'
const showModal = ref(true) // Controls whether the modal is visible or not const showModal = ref(true) // Controls whether the modal is visible or not
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const closeModal = () => { const closeModal = () => {
showModal.value = false showModal.value = false
} }
const defineComponent = () => ({ const defineComponent = () => ({
components: { components: {
Vue3Lottie, Vue3Lottie,
@@ -24,11 +22,11 @@ const defineComponent = () => ({
} }
}, },
}) })
const backtoMap = async () => { const backtoMap = async () => {
try { try {
router.push({ name: 'map' }) router.push({ name: 'map' })
} catch (err) { } catch (err) {
// useToast().error('Failed to load map')
toast.error('無法讀取地圖') toast.error('無法讀取地圖')
console.error(err) console.error(err)
} }
@@ -62,6 +60,17 @@ setTimeout(() => {
<p class="text-base text-text text-center"> <p class="text-base text-text text-center">
接下來回到地圖,迎接新的挑戰吧! 接下來回到地圖,迎接新的挑戰吧!
</p> </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 <a
id="BacktoMap" id="BacktoMap"
target="_blank" target="_blank"
@@ -70,21 +79,6 @@ setTimeout(() => {
>回到地圖</a >回到地圖</a
> >
</div> </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> </div>
</template> </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> </script>
<template> <template>
<a href="#" :class="nodeClass()"> <a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
<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>
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0"> <ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
<li :class="nodeClass()" v-for="child in node.children" :key="child.path"> <li :class="nodeClass()" v-for="child in node.children" :key="child.path">
<FileNode :node="child" :pwd="pwd" /> <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,23 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useSession } from '../../store/session' import sessionStore from '../store/session'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from './MarkdownText.vue'
const sessionStore = useSession()
const current = ref(-1) const current = ref(-1)
const hintDetails = ref<HTMLDetailsElement | null>(null) watch(
const length = computed(() => { () => sessionStore.session.hints,
return sessionStore.session?.responses?.length ?? 0 (hints) => {
}) current.value = hints.length - 1
},
const disabled = computed(() => { { deep: true }
return current.value === length.value - 1 )
})
watch(length, () => {
console.log('length update', length.value)
current.value = length.value - 1
})
const left = () => { const left = () => {
if (current.value > 0) { if (current.value > 0) {
@@ -26,33 +19,24 @@ const left = () => {
} }
const right = () => { const right = () => {
if (current.value < length.value - 1) { if (current.value < sessionStore.session.hints.length - 1) {
current.value += 1 current.value += 1
} }
} }
const response = computed(() => {
if (!sessionStore.session) return null
if (current.value < 0) return null
if (current.value >= sessionStore.session.responses.length) return null
return sessionStore.session.responses[current.value]
})
watch(response, () => {
if (hintDetails.value) {
hintDetails.value.open = false
}
})
</script> </script>
<template> <template>
<div class="h-full flex flex-col"> <div class="h-full flex flex-col">
<!-- Hint Header -->
<div class="flex items-center p-2 h-10"> <div class="flex items-center p-2 h-10">
<font-awesome-icon <font-awesome-icon
:icon="['far', 'lightbulb']" :icon="['far', 'lightbulb']"
class="text-yellow-200 p-2 content-center" class="text-yellow-200 p-2 content-center"
/> />
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">說明</h1> <h1 class="inline text-text w-1/2 font-xl p-2 m-1">
<!-- Hint -->
提示
</h1>
<div v-if="current !== -1" class="flex w-full justify-end items-end"> <div v-if="current !== -1" class="flex w-full justify-end items-end">
<button <button
class="p-2 m-1 w-1/8" class="p-2 m-1 w-1/8"
@@ -69,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">{{ 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">/</p>
<p class="inline text-text font-xl w-1/14 p-2 m-1"> <p class="inline text-text font-xl w-1/14 p-2 m-1">
{{ length }} {{ sessionStore.session.hints.length }}
</p> </p>
<button <button
class="p-2 m-1 w-1/8" class="p-2 m-1 w-1/8"
id="Rbutton" id="Rbutton"
@click="right" @click="right"
:disabled="disabled" :disabled="current === sessionStore.session.hints.length - 1"
> >
<font-awesome-icon <font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
:icon="['fas', 'arrow-right']"
:class="disabled ? 'text-gray-600' : 'text-text'"
/>
</button> </button>
</div> </div>
</div> </div>
<div id="hint" class="bg-bg p-8 overflow-y-auto"> <div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul> <ul>
<li <li
v-if="response" v-for="response in sessionStore.session.responses[current]"
:key="response.type" :key="response.type"
class="text-text font-xl whitespace-pre-wrap" class="text-text font-xl whitespace-pre-wrap"
> >
<MarkdownText :content="response.content" /> <div v-for="content in response.content" :key="content">
<MarkdownText :content="content" />
</div>
</li> </li>
<li <li
v-if="response?.hint" v-for="hint in sessionStore.session.hints[current]"
:key="hint"
class="text-text-primary font-xl whitespace-pre-wrap" class="text-text-primary font-xl whitespace-pre-wrap"
> >
<details ref="hintDetails"> <MarkdownText :content="hint" />
<summary class="cursor-pointer">提示</summary>
<MarkdownText :content="response.hint" />
</details>
</li> </li>
</ul> </ul>
<br /> <br />
+4 -27
View File
@@ -25,37 +25,14 @@ const html = computed(() => {
line-height: 1; line-height: 1;
} }
.markdown-content :deep(p) {
line-height: 1.5;
}
.markdown-content :deep(ol) { .markdown-content :deep(ol) {
list-style: decimal; list-style: decimal;
padding-left: 1em; padding-left: 1em;
line-height: 1.2;
} }
.markdown-content :deep(ul) { .markdown-content :deep(code) {
list-style: disc; background-color: #282a33;
padding-left: 1em;
line-height: 1.2;
}
.markdown-content :deep(pre) {
background-color: #2e3440;
border-radius: 0.5rem;
padding: 0.5rem;
font-size: 0.9rem;
color: #d8dee9;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #4c566a;
line-height: 1.5;
font-family: 'monospace';
}
.markdown-content :deep(code:not(pre code)) {
background-color: #2e3440;
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 0.2rem 0.4rem; padding: 0.25rem;
font-size: 0.9rem; font-size: 0.875rem;
color: #d8dee9;
font-family: 'monospace';
} }
</style> </style>
-121
View File
@@ -1,121 +0,0 @@
<script setup lang="ts">
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 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('你還沒完成前一個關卡!')
}
}
const handleColour = () => {
if (questUnlocked) {
questColor.value = '#00ff00'
questTextColor.value = '#000000'
} else {
questColor.value = '#505050'
questTextColor.value = '#a0a0a0'
}
}
onMounted(async () => {
const quest = await trpc.quests.getQuestDetail.query(questId)
questInstruction.value = quest.instruction
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 text-lg"
>開始冒險</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"> <script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { DAG } from '../../../packages/utils' import { use, init } from 'echarts/core'
import { trpc } from '../utils/trpc' import { GraphChart } from 'echarts/charts'
import useUserProfile from '../store/userProfile' import { SVGRenderer } from 'echarts/renderers'
import QuestIntro from './QuestIntro.vue' 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 store = useUserProfile() const marginX = 500
const toast = useToast() const marginY = 300
const chartContainer = ref(null)
let chartInstance = null
const fullwidth = window.screen.width
const svgWidth = ref(window.innerWidth) use([
const svgHeight = ref(window.innerHeight) GraphChart,
SVGRenderer,
TitleComponent,
TooltipComponent,
DataZoomComponent,
])
const marginX = 120 async function getQuests() {
const marginY = 120 try {
const res = await api.get('/quests')
return res.data
} catch (err) {
console.error(err)
const nodeWidth = 160 throw err
const nodeHeight = 50 }
const opened = ref<Node | null>(null)
type Node = {
id: string
title: string
x: number
y: number
index: number
completed: boolean
unlocked: boolean
} }
type Edge = { async function getProgress() {
source: Node try {
target: Node 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[] }>({ const genOption = (nodes: any, edges: any) => ({
nodes: [], renderer: 'svg',
edges: [], 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) function getOption(quests: any, progress: any) {
const dragStartX = ref(0) const dag = new DAG(quests)
const dragStartY = ref(0)
const offsetX = ref(0)
const offsetY = ref(0)
function startDrag(event: MouseEvent) { const nodes = dag.getNodes().map((node) => ({
isDragging.value = true id: node._id,
dragStartX.value = event.clientX - offsetX.value name: node.title,
dragStartY.value = event.clientY - offsetY.value y: marginX * node.index - (marginX * dag.getLayer(node._id)) / 2,
} x: marginY * node.layer * 2,
completed: progress[node._id]?.completed || false,
function drag(event: MouseEvent) { unlocked: node.requirements.every((req: any) => progress[req]?.completed),
if (!isDragging.value) return }))
offsetX.value = event.clientX - dragStartX.value const lineAppearence = (node: any) => {
offsetY.value = event.clientY - dragStartY.value try {
} const { unlocked } = node
return {
function endDrag() { type: unlocked ? 'line' : 'dashed',
isDragging.value = false color: unlocked ? '#ADADB5' : '#454552',
} }
} catch (err) {
async function computeGraphData() { console.error(err)
if (!store.progress) return throw err
}
try {
const quests = await trpc.quests.getQuests.query()
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,
y:
marginY * node.index -
(marginX * dag.getLayer(node.id)) / 2 +
svgHeight.value / 2,
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('Error computing graph data:', error)
toast.error('Failed to load quest data')
} }
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 const router = useRouter()
} const toast = useToast()
function closeIntro(close: boolean) {
if (close) { function initChart(option: any) {
opened.value = null 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 () => { onMounted(async () => {
await store.loadUserProfile() const quests = await getQuests()
await computeGraphData() const progress = await getProgress()
}) const option = getOption(quests, progress)
initChart(option)
watch(() => store.progress, computeGraphData, { deep: true })
const curvedPath = computed(() => {
return (source: Node, target: Node) => {
// const dx = target.x - source.x
// const dy = target.y - source.y
// const dr = Math.sqrt(dx * dx + dy * dy)
const dr = 0
const sweep = target.index > source.index ? 0 : 1 // Determines if the arc should curve up or down based on node index
return `M${source.x},${source.y}A${dr},${dr} 0 0,${sweep} ${target.x},${target.y}`
}
})
const 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'
}
}) })
</script> </script>
<template> <template>
<div class="relative bg-black w-full h-full flex"> <div class="relative bg-black w-full h-full flex">
<div <img
class="flex place-content-center w-full bg-catelogbg bg-cover bg-scroll" 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 踏上你的Linux冒險之旅吧!
class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl" </h1>
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00" <div class="flex flex-wrap absolute w-full h-full z-1">
> <div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
踏上你的Linux冒險之旅吧!
</h1>
<svg
:width="svgWidth"
:height="svgHeight"
class="absolute"
@mousedown="startDrag"
@mousemove="drag"
@mouseup="endDrag"
@mouseleave="endDrag"
>
<g :transform="`translate(${offsetX}, ${offsetY})`">
<g
v-for="edge in graphData.edges"
:key="`${edge.source.id}-${edge.target.id}`"
>
<path
:d="curvedPath(edge.source, edge.target)"
:class="['edge', edgeStyle(edge)]"
/>
</g>
<g
v-for="node in graphData.nodes"
:key="node.id"
@click="handleNodeClick(node)"
>
<rect
:x="node.x - nodeWidth / 2"
:y="node.y - nodeHeight / 2"
:width="nodeWidth"
:height="nodeHeight"
rx="10"
ry="10"
:class="['node', nodeStyle(node)]"
/>
<text
:x="node.x"
:y="node.y"
text-anchor="middle"
alignment-baseline="middle"
:class="['node-text', nodeStyle(node)]"
font-size="18"
>
{{ node.title }}
</text>
</g>
</g>
</svg>
<QuestIntro
:questTitle="opened.title"
:questId="opened.id"
:questUnlocked="opened.unlocked"
v-if="opened"
@close-intro="closeIntro"
/>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
svg {
overflow: visible;
}
.node {
&.completed {
fill: #6cf76c;
}
&.unlocked {
fill: #8c8c92;
}
&.locked {
fill: #505050;
}
}
.node-text {
&.completed {
fill: #1d1d1d;
}
&.unlocked {
fill: #ffffff;
}
&.locked {
fill: #a0a0a0;
}
}
.edge {
stroke-width: 3;
fill: none;
&.unlocked {
stroke: #adadb5;
stroke-dasharray: none;
}
&.locked {
stroke: #454552;
stroke-dasharray: 5, 5;
}
}
</style>
@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from './MarkdownText.vue'
import { useSession } from '../../store/session' import sessionStore from '../store/session'
import { Task } from '../types'
const sessionStore = useSession()
const tasks = computed(() => { const tasks = computed(() => {
console.log('session', sessionStore.session) return sessionStore.session.tasks.map((task: Task) => {
return sessionStore.session?.tasks.map((task) => {
const prefix = task.completed ? '✓' : '➤' const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text' const color = task.completed ? 'text-text-primary' : 'text-text'
return { return {
+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"> <script setup lang="ts">
import FileNode from '../FileNode.vue' import FileNode from './FileNode.vue'
import { useSession } from '../../store/session' import sessionStore from '../store/session'
const sessionStore = useSession()
</script> </script>
<template> <template>
@@ -10,7 +8,6 @@ const sessionStore = useSession()
<ul> <ul>
<li> <li>
<FileNode <FileNode
v-if="sessionStore.session"
:node="sessionStore.session.graph" :node="sessionStore.session.graph"
:pwd="sessionStore.session.pwd" :pwd="sessionStore.session.pwd"
/> />
@@ -48,14 +45,14 @@ const sessionStore = useSession()
position: absolute; position: absolute;
top: 0; top: 0;
right: 50%; right: 50%;
border-top: 2px solid #00ff00; border-top: 1px solid #00ff00;
width: 50%; width: 50%;
height: 20px; height: 20px;
} }
.tree li::after { .tree li::after {
right: auto; right: auto;
left: 50%; left: 50%;
border-left: 2px solid #00ff00; border-left: 1px solid #00ff00;
} }
.tree li:only-child::after, .tree li:only-child::after,
@@ -73,7 +70,7 @@ const sessionStore = useSession()
} }
.tree li:last-child::before { .tree li:last-child::before {
border-right: 2px solid #00ff00; border-right: 1px solid #00ff00;
border-radius: 0 5px 0 0; border-radius: 0 5px 0 0;
-webkit-border-radius: 0 5px 0 0; -webkit-border-radius: 0 5px 0 0;
-moz-border-radius: 0 5px 0 0; -moz-border-radius: 0 5px 0 0;
@@ -89,7 +86,7 @@ const sessionStore = useSession()
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: 50%;
border-left: 2px solid #00ff00; border-left: 1px solid #00ff00;
width: 0; width: 0;
height: 20px; height: 20px;
} }
@@ -100,7 +97,7 @@ const sessionStore = useSession()
text-decoration: none; text-decoration: none;
color: #ffffff; color: #ffffff;
font-family: arial, verdana, tahoma; font-family: arial, verdana, tahoma;
font-size: 12px; font-size: 11px;
display: inline-block; display: inline-block;
border-radius: 5px; border-radius: 5px;
@@ -112,10 +109,18 @@ const sessionStore = useSession()
-moz-transition: all 0.5s; -moz-transition: all 0.5s;
} }
.tree li a.pwd { a.discovered {
border: 3px solid #00ff00; background: #526b4e;
background: #008080; color: #000;
font-weight: bold; border: 1px solid #94a0b4;
font-size: 13px; }
ul.discovered,
li.discovered {
border-color: #94a0b4;
}
a.pwd {
box-shadow: 0 0 40px rgba(255, 255, 0, 20);
} }
</style> </style>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { useSession } from '../../store/session'
import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
const router = useRouter()
const sessionStore = useSession()
const reset = async () => {
console.log('resetting...')
await sessionStore.createSession()
}
const continuePlay = async () => {
try {
router.push({ name: 'map' })
} catch (err) {
useToast().error('無法讀取地圖')
console.error(err)
}
}
</script>
<template>
<section class="mt-2 flex flex-row gap-3 justify-center">
<button
id="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 inline md:flex">問卷</p>
</button>
<button
title="Bug Report"
@click="openBugReport"
class="bg-bg-secondary rounded-lg p-2"
>
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
<p class="text-text-primary inline ml-1 md:flex">回報</p>
</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" class="bg-bg-secondary rounded-lg p-2" @click="reset">
<font-awesome-icon
:icon="['fas', 'arrow-rotate-left']"
class="text-text-primary px-1"
/>
<p class="text-text-primary inline ml-1 md:flex">重來</p>
</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 px-1"
/>
<p class="text-text-primary inline ml-1 md:flex">繼續</p>
</button>
</section>
</template>
-37
View File
@@ -1,37 +0,0 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { computed } from 'vue'
import { useSession } from '../../store/session'
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">開始</p>
</button>
</div>
</template>
-44
View File
@@ -1,44 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useSession, useTerminal } from '../../store/session'
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" />
<button id="Terminal" class="text-text">終端機</button>
<a
class="text-sm font-normal text-gray-200 ml-10"
:href="`/terminal/${sessionStore.session?.containerName}?token=${sessionStore.session?.token}`"
target="_blank"
>
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,112 +0,0 @@
<script setup lang="ts">
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 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('登出失敗')
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('無法讀取使用者資料')
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="Survey"
target="_blank"
class="md:h-5 md:w-5 px-1.5 w-auto"
@click="openSurvey"
>
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary inline"
/>
<span class="text-text sm:hidden px-2">Survey</span>
</button>
<RouterLink
title="LeaderBoard"
to="/leaderboard"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon :icon="['fas', 'trophy']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Leader Board</span>
</RouterLink>
<RouterLink title="Map" to="/map" class="md:h-5 md:w-5 px-1.5 w-auto">
<font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Map</span>
</RouterLink>
<button
title="Bug Report"
@click="openBugReport"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Bug Report</span>
</button>
<button
title="Sign Out"
@click="handleLogout"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon
:icon="['fas', 'arrow-right-from-bracket']"
class="text-text-primary"
/>
<span class="text-text sm:hidden px-2">Sign Out</span>
</button>
</template>
-66
View File
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { defineProps, ref } from 'vue'
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-5 fixed flex flex-row flex-wrap bg-bg-secondary justify-between items-center sm:justify-between"
>
<div class="flex flex-row items-center gap-3" :class="{ hidden: menuOpen }">
<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" />
</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,62 +0,0 @@
<script setup lang="ts">
import { bugReportUrl } from '../../config'
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"
>
前言
</button>
<button
id="Feature"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('feature')"
style="font-size: 2vh"
>
特色</button
><button
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"
>
關於
</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"
>
聯絡我們
</button>
<RouterLink
title="Sign In"
id="SignIn"
to="/register"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
style="font-size: 2vh"
>
登入
</RouterLink>
<a title="Bug Report" :href="bugReportUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
</a>
</template>
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 specific icons */
import { import {
faArrowLeft,
faArrowRight,
faArrowRightFromBracket,
faArrowRotateLeft,
faBars, faBars,
faArrowLeft,
faList,
faGear,
faTriangleExclamation,
faExpand,
faTerminal,
faArrowRotateLeft,
faArrowRightFromBracket,
faArrowRight,
faBug, faBug,
faCircleExclamation, faCircleExclamation,
faEnvelope,
faExpand,
faFileInvoice,
faGear,
faList,
faMap, faMap,
faPlay,
faTerminal,
faTriangleExclamation,
faTrophy, faTrophy,
faFileInvoice,
faEnvelope,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { import {
faLightbulb,
faCircleQuestion, faCircleQuestion,
faCircleRight, faCircleRight,
faFile,
faFolder,
faLightbulb,
} from '@fortawesome/free-regular-svg-icons' } from '@fortawesome/free-regular-svg-icons'
import { import {
faFacebook, faFacebook,
@@ -36,29 +33,26 @@ import {
/* add icons to the library */ /* add icons to the library */
library.add( library.add(
faArrowLeft,
faArrowRight,
faArrowRightFromBracket,
faArrowRotateLeft,
faBars, faBars,
faBug, faArrowLeft,
faCircleExclamation,
faEnvelope,
faExpand,
faFileInvoice,
faGear,
faList, faList,
faMap, faGear,
faPlay,
faTerminal,
faTriangleExclamation, faTriangleExclamation,
faExpand,
faTerminal,
faArrowRotateLeft,
faArrowRightFromBracket,
faArrowRight,
faBug,
faMap,
faTrophy, faTrophy,
faCircleExclamation,
faLightbulb,
faCircleQuestion, faCircleQuestion,
faCircleRight, faCircleRight,
faFile,
faFolder,
faLightbulb,
faFacebook,
faGithub, faGithub,
faGoogle faGoogle,
faFileInvoice,
faFacebook,
faEnvelope
) )
+1 -11
View File
@@ -1,6 +1,4 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css' import './style.css'
/* import font awesome icon component */ /* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
@@ -10,24 +8,16 @@ import Toast, { POSITION } from 'vue-toastification'
/* import Vue Lottie */ /* import Vue Lottie */
import Vue3Lottie from 'vue3-lottie' import Vue3Lottie from 'vue3-lottie'
import 'vue-toastification/dist/index.css' import 'vue-toastification/dist/index.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import formbricks from './utils/formbricks'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia()
app.component('font-awesome-icon', FontAwesomeIcon) app.component('font-awesome-icon', FontAwesomeIcon)
app.use(pinia)
app.use(router) app.use(router)
router.afterEach(() => {
if (typeof formbricks !== 'undefined') {
formbricks.registerRouteChange()
}
})
const options = { const options = {
position: POSITION.TOP_RIGHT, position: POSITION.TOP_RIGHT,
} }
+141 -113
View File
@@ -1,128 +1,156 @@
import { defineStore } from 'pinia' import { reactive } from 'vue'
import { import { useToast } from 'vue-toastification'
FileGraph, import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
type FileGraphUpdateEvent, import { IQuest } from '@linux-odyssey/models'
} from '../../../packages/file-graph' import api from '../utils/api'
import type { IResponse, ITask } from '../../../packages/game'
import type { SessionDetail } from '../../../server/src/routers/sessionRouter'
import Socket from '../utils/socket' import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal' import SocketTerminal from '../utils/terminal'
import type { Session } from '../types' import { LoadQuestError, LoadSessionError } from '../utils/errors'
import { trpc } from '../utils/trpc' import { Session, StageResponse } from '../types'
const socket = new Socket() const toast = useToast()
const term = new SocketTerminal()
let hasSetup = false
interface QuestDetailResponse { function newSession() {
id: string const graph = new FileGraph({
title: string path: '/',
instruction: string type: 'folder',
requirements: string[] discovered: false,
} })
return {
interface SessionUpdate { _id: '',
status: string status: 'inactive',
responses: IResponse[] graph,
tasks: ITask[] pwd: '',
graphUpdate: FileGraphUpdateEvent hints: [],
tasks: [],
responses: [],
}
} }
interface Store { interface Store {
session: Session | null session: Session
questId: string questId: string
quest: QuestDetailResponse | null quest: IQuest | null
} }
export const useSession = defineStore('session', { const store = reactive<Store>({
state: (): Store => ({ session: newSession(),
session: null, questId: '',
questId: '', quest: null,
quest: null,
}),
actions: {
async setQuest(questId: string) {
this.quest = await trpc.quests.getQuestDetail.query(questId)
this.questId = questId
},
async setSession(session: SessionDetail) {
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 session = await trpc.session.createSession.mutate({
questId: this.questId,
})
await this.setSession(session)
term.send('echo start\n')
},
async getActiveSession() {
const session = await trpc.session.getActiveSession.query({
questId: this.questId,
})
if (session) {
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 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() { export function useTerminal() {
return term 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 { reactive } from 'vue'
import { trpc } from '../utils/trpc' import api from '../utils/api'
interface IProgress { const store = reactive({
quest: string username: '',
sessions: string[] email: '',
completed: boolean progress: {},
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()
},
},
}) })
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 { FileGraph } from '@linux-odyssey/file-graph'
import type { IResponse, ITask } from '../../packages/game' // eslint-disable-next-line import/no-relative-packages
import { IResponse } from '@linux-odyssey/models'
export interface Task { export interface Task {
id: number id: number
@@ -19,8 +20,7 @@ export interface Session {
status: string status: string
graph: FileGraph graph: FileGraph
pwd: string pwd: string
tasks: ITask[] hints: string[][]
responses: IResponse[] tasks: Task[]
token: string responses: IResponse[][]
containerName: string
} }
-4
View File
@@ -1,4 +0,0 @@
declare module '*.css' {
const content: string
export default content
}
-40
View File
@@ -1,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 { io, Socket } from 'socket.io-client'
import type { Session } from '../types' import { Session } from '../types'
type SocketCallback = (data: any) => void type SocketCallback = (data: any) => void
@@ -23,9 +23,11 @@ class SocketWrapper {
this.socket.on('connect', () => { this.socket.on('connect', () => {
console.log('Socket connected to session', session._id) console.log('Socket connected to session', session._id)
resolve()
}) })
this.socket.once('terminal', () => {
resolve()
})
this.socket.on('connect_error', reject) this.socket.on('connect_error', reject)
this.bindListeners() this.bindListeners()
}) })
+28 -63
View File
@@ -1,92 +1,57 @@
import { Terminal } from 'xterm' import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit' import { FitAddon } from 'xterm-addon-fit'
import ReconnectingWebSocket from 'reconnecting-websocket'
import 'xterm/css/xterm.css' import 'xterm/css/xterm.css'
class SocketTerminal { class SocketTerminal {
private term: Terminal private term: Terminal
private fitAddon: FitAddon private fitAddon: FitAddon
private socket: ReconnectingWebSocket | null = null
constructor() { constructor(rows: number, cols: number) {
this.term = new Terminal({ this.term = new Terminal({
convertEol: true, rows, // 行数
scrollback: 1000, cols, // 不指定行数,自动回车后光标从下一行开始
fontSize: 16, convertEol: true, // 启用时,光标将设置为下一行的开头
// scrollback: 50, //终端中的回滚量
disableStdin: false, // 是否应禁用输入
// cursorStyle: "underline", //光标样式
cursorBlink: true, // 光标闪烁
theme: { theme: {
foreground: '#ECECEC', foreground: '#ECECEC', // 字体
background: '#000000', background: '#000000', // 背景色
cursor: 'help', cursor: 'help', // 设置光标
}, },
fontSize: 18,
fontWeight: 'normal',
}) })
this.fitAddon = new FitAddon() this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon) this.term.loadAddon(this.fitAddon)
this.term.onData((data) => { this.fitAddon.fit()
if (this.socket) {
this.socket.send(data)
}
})
} }
connect(url: string): Promise<void> { write(message: string): void {
return new Promise((resolve, reject) => { this.term.write(message)
console.log('Connecting to terminal', url)
this.term.write('\r\n\x1B[1;32mConnecting to terminal\x1B[0m\r\n')
this.socket = new ReconnectingWebSocket(url, [], {
connectionTimeout: 5000,
maxRetries: 10,
})
this.socket.onopen = () => {
console.log('Connected to terminal')
this.term.reset()
this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n')
resolve()
}
this.socket.onclose = () => {
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)
}
})
} }
resizeScreen(): void { onData(callback: any): void {
try { this.term.onData(callback)
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)
}
} }
focus(): void { focus(): void {
this.term.focus() this.term.focus()
} }
resizeScreen(): void {
this.fitAddon.fit()
}
mount(reference: HTMLElement): void {
this.term.open(reference)
this.term.focus()
this.resizeScreen()
}
reset(): void { reset(): void {
this.term.reset() this.term.reset()
this.socket?.close()
} }
} }
-16
View File
@@ -1,16 +0,0 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../../../server/src/routers'
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/trpc',
// You can pass any HTTP headers you wish here
// async headers() {
// return {
// authorization: getAuthCookie(),
// }
// },
}),
],
})
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { isValidUsername } from '../../../packages/utils' import { isValidUsername } from '@linux-odyssey/utils'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue' import Background from '../components/DynamicBackground.vue'
import { TooManyRequestsError, ValidationError } from '../utils/errors' import { TooManyRequestsError, ValidationError } from '../utils/errors'
+35 -38
View File
@@ -1,30 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted } from 'vue'
import { useSession } from '../store/session' import { useToast } from 'vue-toastification'
import useUserProfile from '../store/userProfile' import sessionStore, { init } from '../store/session'
import HeaderPart from '../components/header/HeaderPart.vue' import GameHeaderPart from '../components/GameHeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue' // import CommandlistPart from '../components/CommandlistPart.vue'
import DescriptionPart from '../components/game/DescriptionPart.vue' import HintPart from '../components/HintPart.vue'
import QuestPart from '../components/game/QuestPart.vue' import QuestPart from '../components/QuestPart.vue'
import TerminalPart from '../components/game/TerminalPart.vue' import TerminalPart from '../components/TerminalPart.vue'
import VisualizationPart from '../components/game/VisualizationPart.vue' import VisualizationPart from '../components/VisualizationPart.vue'
import ControlPalette from '../components/game/ControlPalette.vue' import ControlPalette from '../components/ControlPalette.vue'
import CompleteModal from '../components/game/CompleteModal.vue' import CompleteModal from '../components/CompleteModal.vue'
import StartButton from '../components/game/StartButton.vue' import { LoadQuestError, LoadSessionError } from '../utils/errors'
import { openQuestSurvey } from '../utils/formbricks'
const sessionStore = useSession()
const userStore = useUserProfile()
const completed = computed(() => { const completed = computed(() => {
return sessionStore.session?.status === 'finished' return sessionStore.session.status === 'finished'
})
watch(completed, (newValue, oldValue) => {
if (newValue && !oldValue) {
console.log('finish', userStore)
openQuestSurvey()
}
}) })
const props = defineProps({ const props = defineProps({
@@ -34,30 +23,39 @@ const props = defineProps({
}, },
}) })
const toast = useToast()
onMounted(async () => { onMounted(async () => {
sessionStore.reset() try {
sessionStore.setup() await init(props.questId)
await sessionStore.setQuest(props.questId) } catch (err: any) {
await sessionStore.getActiveSession() 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> </script>
<template> <template>
<!-- game header --> <!-- game header -->
<div class="w-full"> <div class="h-[6vh] w-full">
<HeaderPart <GameHeaderPart :title="sessionStore.quest?.title" />
:title="sessionStore.quest?.title"
:headerComponent="GameHeaderComponents"
/>
</div> </div>
<!-- current status indicator --> <!-- current status indicator -->
<input <input
type="hidden" type="hidden"
id="currentStatus" id="currentStatus"
:value="sessionStore.session?.status" :value="sessionStore.session.status"
/> />
<!-- main --> <!-- 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 --> <!-- Topic and Command List -->
<div class="bg-bg-secondary h-full w-1/4 rounded-lg"> <div class="bg-bg-secondary h-full w-1/4 rounded-lg">
<section id="quest" class="h-full p-3 overflow-auto"> <section id="quest" class="h-full p-3 overflow-auto">
@@ -69,10 +67,9 @@ onMounted(async () => {
</section> --> </section> -->
</div> </div>
<!-- Terminal and Hint --> <!-- Terminal and Hint -->
<div class="bg-bg h-full rounded-lg w-1/2"> <div class="bg-bg h-full w-1/2 rounded-lg">
<StartButton :questId="$props.questId" />
<section id="hint" class="h-3/5"> <section id="hint" class="h-3/5">
<DescriptionPart /> <HintPart />
</section> </section>
<hr class="border-border border" /> <hr class="border-border border" />
<section id="terminal" class="h-2/5 overflow-hidden"> <section id="terminal" class="h-2/5 overflow-hidden">
+119 -45
View File
@@ -1,19 +1,97 @@
<script setup lang="ts"> <script setup lang="ts">
import ContactButton from '../components/ContactButton.vue' import ContactButton from '../components/ContactButton.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import LandingHeaderComponents from '../components/header/LandingHeaderComponents.vue' import { bugReportUrl, facebookUrl, githubUrl } from '../config'
import HeaderPart from '../components/header/HeaderPart.vue'
import { facebookUrl, githubUrl } from '../config' 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> </script>
<template> <template>
<div class="w-screen h-screen bg-scroll bg-bg"> <div class="w-screen h-screen bg-scroll bg-bg">
<DynamicBackground class="w-full h-full" /> <DynamicBackground class="w-full h-full" />
<div class="h-screen w-screen absolute top-0 left-0"> <div class="h-screen w-screen absolute top-0 left-0">
<!-- game header --> <!-- 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 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="w-full flex flex-col items-center">
<div <div
class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10" class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10"
@@ -26,6 +104,7 @@ import { facebookUrl, githubUrl } from '../config'
Odyssey Odyssey
</h1> </h1>
</div> </div>
<!-- intro --> <!-- intro -->
<div class="w-full md:w-2/3 px-5"> <div class="w-full md:w-2/3 px-5">
<section <section
@@ -149,43 +228,40 @@ import { facebookUrl, githubUrl } from '../config'
鈦坦敏捷開發特別獎 第二名 鈦坦敏捷開發特別獎 第二名
</h1> </h1>
</div> </div>
<div class="container mx-auto">
<div <!-- <h1
class="justify-items-center grid grid-cols-1 sm:grid-cols-3 gap-5" class="text-text-primary p-5 font-extrabold font-mono col-span-3 gap-3"
> style="font-size: 3.5vh"
<h1 >
class="text-text-primary text-3xl font-extrabold font-mono sm:col-span-3 gap-6" 玩家感想
> </h1>
玩家感想 <h1 class="text-text p-3" style="font-size: 2.5vh">
<!--可以做成翻頁卡牌一次一個--> "很好玩很像益智遊戲"
</h1> </h1>
<h1 class="text-xl text-center">"很好玩很像益智遊戲"</h1> <h1 class="text-text p-3" style="font-size: 2.5vh">
<h1 class="text-xl text-center"> "我從來不知道自由軟體的存在,謝謝你們。"
"我從來不知道自由軟體的存在,謝謝你們。" </h1>
</h1> <h1 class="text-text p-3" style="font-size: 2.5vh">
<h1 class="text-xl text-center"> "還蠻好玩的,讓程式變好玩"
"還蠻好玩的,讓程式變好玩" </h1>
</h1> <h1 class="text-text p-3" style="font-size: 2.5vh">
<h1 class="text-xl text-center"> "以前不喜歡電腦課現在覺得喜歡一點點"
"以前不喜歡電腦課現在覺得喜歡一點點" </h1>
</h1> <h1 class="text-text p-3" style="font-size: 2.5vh">
<h1 class="text-xl text-center"> "程式真的對新手很友善,讓我學到了很多東西"
"程式真的對新手很友善,讓我學到了很多東西" </h1>
</h1> <h1 class="text-text p-3" style="font-size: 2.5vh">
<h1 class="text-xl text-center"> "感覺做得很好很簡明易懂關卡設計很用心"
"感覺做得很好很簡明易懂關卡設計很用心" </h1>
</h1> <h1 class="text-text p-3" style="font-size: 2.5vh">
<h1 class="text-xl text-center"> "這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動"
"這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動" </h1>
</h1> <h1 class="text-text p-3" style="font-size: 2.5vh">
<h1 class="text-xl text-center"> "以前我覺得寫程式很難不過現在覺得很有趣"
"以前我覺得寫程式很難不過現在覺得很有趣" </h1>
</h1> <h1 class="text-text p-3" style="font-size: 2.5vh">
<h1 class="text-xl text-center"> "以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近"
"以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近" </h1> -->
</h1>
</div>
</div>
</section> </section>
</div> </div>
<section <section
@@ -195,9 +271,7 @@ import { facebookUrl, githubUrl } from '../config'
<h1 class="text-text-primary font-extrabold font-mono text-3xl"> <h1 class="text-text-primary font-extrabold font-mono text-3xl">
聯絡我們 聯絡我們
</h1> </h1>
<div <div class="flex flex-row items-center justify-around mt-5 gap-5">
class="flex flex-row items-center justify-around mt-5 gap-5 mb-5"
>
<ContactButton <ContactButton
:url="facebookUrl" :url="facebookUrl"
:icons="['fab', 'facebook']" :icons="['fab', 'facebook']"
+3 -4
View File
@@ -2,8 +2,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import GameHeaderPart from '../components/GameHeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
import api from '../utils/api' import api from '../utils/api'
const leaderboard = ref< const leaderboard = ref<
@@ -36,8 +35,8 @@ onMounted(async () => {
<div <div
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<div class="w-full"> <div class="h-[6vh] w-full">
<HeaderPart title="排行榜" :headerComponent="GameHeaderComponents" /> <GameHeaderPart title="排行榜" />
</div> </div>
<div class="w-fit h-[94vh] m-3"> <div class="w-fit h-[94vh] m-3">
<div <div
+2 -4
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import Background from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import { import {
TooManyRequestsError, TooManyRequestsError,
@@ -46,11 +45,10 @@ const handleLogin = async ({
<template> <template>
<div class="w-screen h-screen"> <div class="w-screen h-screen">
<DynamicBackground class="w-full h-full" /> <Background class="w-full h-full" />
<div <div
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<HeaderPart />
<div class="w-fit"> <div class="w-fit">
<AuthForm @onSubmit="handleLogin" type="login" /> <AuthForm @onSubmit="handleLogin" type="login" />
</div> </div>
+4 -9
View File
@@ -1,18 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import QuestMap from '../components/QuestMap.vue' import QuestMap from '../components/QuestMap.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import GameHeaderPart from '../components/GameHeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
</script> </script>
<template> <template>
<div class="w-fit"> <div class="h-[6vh] w-full">
<HeaderPart <GameHeaderPart title="關卡地圖" />
title="關卡地圖"
:headerComponent="GameHeaderComponents"
class="z-50"
/>
</div> </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 /> <QuestMap />
</div> </div>
</template> </template>
+6 -7
View File
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import validator from 'validator' import validator from 'validator'
import { isValidUsername } from '../../../packages/utils' import { isValidUsername } from '@linux-odyssey/utils'
import { passwordPolicy } from '../../../packages/constants' import { passwordPolicy } from '@linux-odyssey/constants'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import Background from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import { checkUsername, register } from '../utils/auth' import { checkUsername, register } from '../utils/auth'
import { import {
UnauthorizedError, UnauthorizedError,
@@ -89,12 +89,11 @@ async function check({
<template> <template>
<div class="w-screen h-screen"> <div class="w-screen h-screen">
<DynamicBackground class="w-full h-full" /> <Background class="w-full h-full" />
<div <div
class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<HeaderPart /> <div class="w-fit">
<div class="w-fit mt-8">
<AuthForm <AuthForm
@onSubmit="handleRegister" @onSubmit="handleRegister"
@onChange="check" @onChange="check"
-3
View File
@@ -24,9 +24,6 @@ export default {
DEFAULT: '#FF0000', DEFAULT: '#FF0000',
}, },
}, },
backgroundImage: {
catelogbg: "url('/catelogbg.svg')",
},
}, },
}, },
plugins: [], 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": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "../packages/utils" },
{ "path": "./tsconfig.node.json" } { "path": "../packages/constants" },
{ "path": "../packages/file-graph" },
{ "path": "../packages/models" }
] ]
} }
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-16
View File
@@ -1,27 +1,11 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], 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: { server: {
host: '0.0.0.0',
proxy: { proxy: {
'/trpc': {
target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false,
xfwd: true,
},
'/api': { '/api': {
target: process.env.API_TARGET || 'http://localhost:3000', target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false, changeOrigin: false,
+18 -1
View File
@@ -1,10 +1,27 @@
server { server {
listen 80; listen 80;
error_log /var/log/nginx/error.log debug;
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location /api/ {
proxy_pass http://backend:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api-docs/ {
proxy_pass http://backend:3000;
}
location /socket.io/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
} }
+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: services:
traefik:
image: traefik:v3.6.9
ports:
- 8000:8000
- 8080:8080
networks:
- players
- internal
command:
- '--api.insecure=true'
- '--providers.docker=true'
- '--providers.docker.exposedbydefault=false'
- '--entrypoints.web.address=:8000'
- '--entrypoints.web.forwardedHeaders.insecure=true' # trust all X-Forwarded headers
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
app: app:
image: linuxodyssey/frontend:${IMAGE_TAG:-latest} image: linuxodyssey/frontend
depends_on: depends_on:
- backend - backend
restart: always restart: always
@@ -26,16 +10,12 @@ services:
dockerfile: app/Dockerfile dockerfile: app/Dockerfile
target: prod target: prod
networks: networks:
- internal internal:
labels: ports:
- 'traefik.enable=true' - 8080:80
- 'traefik.http.routers.app.entrypoints=web'
- 'traefik.http.routers.app.rule=PathPrefix(`/`)'
- 'traefik.http.routers.app.service=app'
- 'traefik.http.services.app.loadbalancer.server.port=80'
backend: backend:
image: linuxodyssey/backend:${IMAGE_TAG:-latest} image: linuxodyssey/backend
depends_on: depends_on:
- db - db
restart: always restart: always
@@ -49,35 +29,21 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./logs:/app/logs - ./logs:/app/logs
- ./config:/app/config
- ./quests:/app/quests:ro
env_file: env_file:
- .env.prod - .env
environment: environment:
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
- DOCKER_NETWORK=linux-odyssey-players - DOCKER_NETWORK=linux-odyssey-players
- SECRET_KEY=${SECRET_KEY}
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
- BACKEND_URL=http://backend:3000 - BACKEND_URL=http://backend:3000
- HOST_PROJECT_ROOT=${PWD} - BASE_URL=${BASE_URL}
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.backend-api.entrypoints=web'
- 'traefik.http.routers.backend-api.rule=PathPrefix(`/api`)'
- 'traefik.http.routers.backend-api.service=backend'
- 'traefik.http.routers.backend-socket.entrypoints=web'
- 'traefik.http.routers.backend-socket.rule=PathPrefix(`/socket.io`)'
- 'traefik.http.routers.backend-socket.service=backend'
- 'traefik.http.routers.backend-trpc.entrypoints=web'
- 'traefik.http.routers.backend-trpc.rule=PathPrefix(`/trpc`)'
- 'traefik.http.routers.backend-trpc.service=backend'
- 'traefik.http.routers.backend-api-docs.entrypoints=web'
- 'traefik.http.routers.backend-api-docs.rule=PathPrefix(`/api-docs`)'
- 'traefik.http.routers.backend-api-docs.service=backend'
- 'traefik.http.services.backend.loadbalancer.server.port=3000'
analytics: analytics:
image: linuxodyssey/analytics:${IMAGE_TAG:-latest} image: linuxodyssey/analytics
build: build:
context: . context: .
dockerfile: services/analytics/Dockerfile dockerfile: packages/analytics/Dockerfile
target: prod target: prod
restart: always restart: always
depends_on: depends_on:
@@ -86,11 +52,13 @@ services:
- internal - internal
ports: ports:
- '3001:3001' - '3001:3001'
env_file: environment:
- .env.prod - MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
db: db:
image: mongo:6 image: mongo
restart: always restart: always
networks: networks:
- internal - internal
@@ -99,11 +67,22 @@ services:
volumes: volumes:
- mongo:/data/db - mongo:/data/db
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
env_file: environment:
- .env.prod - 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: base:
image: linuxodyssey/quest-base:${IMAGE_TAG:-latest} image: linuxodyssey/quest-base
build: build:
context: . context: .
dockerfile: ./quests/Dockerfile 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() { dump() {
# Define service name and dump directory name # Define service name and dump directory name
SERVICE_NAME="db" SERVICE_NAME="db"
COMPOSE_FILE="docker-compose.prod.yml"
DUMP_NAME="mongodump-$(date --iso).archive" 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 # 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 echo mongo uri: $MONGO_URL
# Dump MongoDB data using credentials # 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 # Copy the dump from the container to the host
# Note: You will need to know the exact container name for this step # Note: You will need to know the exact container name for this step
-141
View File
@@ -1,141 +0,0 @@
# Windows Environment Setup
## Install WSL and Ubuntu
Please refer to the Official [Setup WSL](https://learn.microsoft.com/en-us/windows/wsl/setup/environment) on Microsoft's website.
Run the following command in PowerShell as Administrator:
wsl --install
Then restart the computer. After restarting, you can open Ubuntu terminal by searching "Ubuntu" in Start menu. You will be prompted to create a new user account. Once you have done that, you can continue with the following steps.
## Setup Environment (In Ubuntu terminal)
1. Update and upgrade
```
sudo apt-get update
sudo apt-get upgrade
```
1. Install Git
```
sudo apt-get install git
```
1. Install Node.js (Version 20.x)
```
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install nodejs
sudo apt install build-essential
sudo npm install -g yarn
```
## Install Docker
(Refer to [get.docker.com](https://get.docker.com/))
To install the latest stable versions of Docker CLI, Docker Engine, and their
dependencies:
1. Download the script
```
curl -fsSL https://get.docker.com -o install-docker.sh
```
1. verify the script's content
```
cat install-docker.sh
```
1. run the script with --dry-run to verify the steps it executes
```
sh install-docker.sh --dry-run
```
**NOTE: the script will recommend you to use Docker Desktop instead, but we need to use Docker inside WSL for the backend to work.** Just ignore it and wait for the installation to finish.
1. run the script either as root, or using sudo to perform the installation.
```
sudo sh install-docker.sh
```
1. Verify the installation
```
docker --version
```
1. Add your user to the docker group
```
sudo usermod -aG docker $USER
```
**NOTE: You need to create a new WSL terminal to apply the changes.**
1. Verify the installation
```
docker ps
```
This should return something like:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
## Run the Project (In Ubuntu terminal)
1. Clone the repository
```
git clone https://github.com/lancatlin/linux-odyssey.git
cd linux-odyssey
```
Optional: To open the project in VSCode, run the following command inside the project folder:
```
code .
```
1. Install dependencies
```
yarn install
```
It is ok if there are some warnings and `gyp: ERR` errors, just verify there is `done` at the end.
1. Copy the environment variables
```
cp .env.sample .env
```
1. Docker setup
```
docker compose pull
docker compose build
docker compose up -d db
```
<!-- Check folder ./config exist and is empty -->
<!-- Check .env file exist -->
1. Run the project
```
yarn build
yarn dev
```
<!-- Check ./config/ssh_key.pub is a file not a folder -->
Enjoy!
+10 -13
View File
@@ -9,7 +9,6 @@
"app", "app",
"server", "server",
"packages/*", "packages/*",
"services/*",
"tests" "tests"
], ],
"scripts": { "scripts": {
@@ -17,6 +16,7 @@
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build", "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\"", "dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
"server": "yarn workspace server dev", "server": "yarn workspace server dev",
"cli": "yarn workspace client start",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --ignore-path .prettierignore --write app server", "format": "prettier --ignore-path .prettierignore --write app server",
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)", "test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
@@ -34,29 +34,26 @@
"@jest/globals": "^29.7.0", "@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
"@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/parser": "^6.14.0",
"@vue/eslint-config-prettier": "^7.1.0", "@vue/eslint-config-prettier": "^7.1.0",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^8.56.0", "eslint": "^8.44.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-cypress": "^2.15.1", "eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-import": "^2.29.0", "eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue": "^9.15.1",
"globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3", "typescript": "^5.3.3"
"vue-eslint-parser": "^9.4.2",
"zod-to-json-schema": "^3.24.5"
}, },
"dependencies": { "dependencies": {
"latest": "^0.2.0" "typescript-eslint": "^7.4.0"
}, }
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }
File renamed without changes.
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-alpine as build
WORKDIR /app
COPY ./package.json yarn.lock tsconfig.json ./
COPY packages/analytics/package.json ./packages/analytics/package.json
COPY packages/models/package.json ./packages/models/package.json
RUN yarn install --frozen-lockfile
ENV HOST=0.0.0.0
ENV PORT=3001
EXPOSE 3001
# Production stage
FROM build as prod
COPY packages/analytics ./packages/analytics
COPY packages/models ./packages/models
ENV NODE_ENV=production
WORKDIR /app/packages/analytics
RUN yarn build
CMD [ "yarn", "start" ]
File renamed without changes.
+6
View File
@@ -0,0 +1,6 @@
{
"watch": ["src", "views"],
"ext": "ts,handlebars,js",
"ignore": ["src/**/*.spec.ts"],
"exec": "node --loader ts-node/esm src/index.ts"
}
@@ -3,27 +3,30 @@
"version": "1.0.0", "version": "1.0.0",
"main": "src/index.js", "main": "src/index.js",
"private": true, "private": true,
"type": "module",
"license": "GPL", "license": "GPL",
"scripts": { "scripts": {
"start": "node dist/services/analytics/src/index.js", "start": "node dist/index.js",
"build": "tsc", "build": "tsc -b -f",
"type-check": "tsc --noEmit", "watch": "tsc -b --watch",
"dev": "tsx watch src/index.ts" "dev": "nodemon"
}, },
"dependencies": { "dependencies": {
"@linux-odyssey/models": "*",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.20.0", "express": "^4.19.2",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"express-handlebars": "^7.1.2", "express-handlebars": "^7.1.2",
"mongoose": "^8.9.5" "mongoose": "^8.0.3"
}, },
"devDependencies": { "devDependencies": {
"@swc/core": "^1.4.2", "@swc/core": "^1.4.2",
"@swc/helpers": "^0.5.6", "@swc/helpers": "^0.5.6",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.11.20", "@types/node": "^20.11.20",
"nodemon": "^3.1.0",
"regenerator-runtime": "^0.14.1", "regenerator-runtime": "^0.14.1",
"tsx": "^4.19.3", "ts-node": "^10.9.2",
"typescript": "^5.3.3" "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 = { const config: Config = {
host: process.env.ADMIN_HOST || 'localhost', host: process.env.HOST || 'localhost',
port: process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001, port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3001,
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test', db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
username: getOrFailed('ADMIN_USERNAME'), username: getOrFailed('ADMIN_USERNAME'),
password: getOrFailed('ADMIN_PASSWORD'), password: getOrFailed('ADMIN_PASSWORD'),
baseUrl: baseUrl:
process.env.ADMIN_BASE_URL || process.env.BASE_URL ||
`http://${process.env.ADMIN_HOST || 'localhost'}:${ `http://${process.env.HOST || 'localhost'}:${process.env.PORT ? parseInt(process.env.PORT, 10) : 3001}`,
process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001
}`,
} }
export default config export default config
@@ -1,6 +1,6 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import Pagination from '../models/pagination' import Pagination from '../models/pagination.js'
import { errorCommands } from '../models/commands' import { errorCommands } from '../models/commands.js'
export async function commandListController( export async function commandListController(
req: Request, req: Request,
@@ -1,6 +1,6 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { userCount } from '../models/users' import { userCount } from '../models/users.js'
import { questList } from '../models/quests' import { questList } from '../models/quests.js'
export async function homeController(req: Request, res: Response) { export async function homeController(req: Request, res: Response) {
const quests = await questList() const quests = await questList()
@@ -1,5 +1,5 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { questList } from '../models/quests' import { questList } from '../models/quests.js'
export async function questListController(req: Request, res: Response) { export async function questListController(req: Request, res: Response) {
const quests = await questList() const quests = await questList()
@@ -1,6 +1,6 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import Pagination from '../models/pagination' import Pagination from '../models/pagination.js'
import { sessionList, sessionDetail } from '../models/sessions' import { sessionList, sessionDetail } from '../models/sessions.js'
export async function sessionListController(req: Request, res: Response) { export async function sessionListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string } const { nextKey, order } = req.query as { nextKey: string; order: string }
@@ -1,7 +1,7 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import mongoose from 'mongoose' import mongoose from 'mongoose'
import Pagination from '../models/pagination' import Pagination from '../models/pagination.js'
import { userList, userDetail, idToUser } from '../models/users' import { userList, userDetail, idToUser } from '../models/users.js'
export async function userListController(req: Request, res: Response) { export async function userListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string } const { nextKey, order } = req.query as { nextKey: string; order: string }
@@ -1,9 +1,9 @@
import connectDB from '@linux-odyssey/models'
import express from 'express' import express from 'express'
import { engine } from 'express-handlebars' import { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth' import basicAuth from 'express-basic-auth'
import { connectDB } from '../../../packages/models' import config from './config.js'
import config from './config' import router from './router.js'
import router from './router'
async function main() { async function main() {
await connectDB(config.db) await connectDB(config.db)
File renamed without changes.
@@ -1,5 +1,5 @@
import { Command } from '../../../../packages/models' import { Command } from '@linux-odyssey/models'
import Pagination from './pagination' import Pagination from './pagination.js'
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export async function errorCommands(pagination: Pagination) { export async function errorCommands(pagination: Pagination) {
@@ -1,5 +1,5 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
/* eslint-disable max-classes-per-file */ // eslint-disable-next-line max-classes-per-file
import mongoose, { Types } from 'mongoose' import mongoose, { Types } from 'mongoose'
export const DESC: string = 'desc' export const DESC: string = 'desc'
@@ -1,14 +1,8 @@
import { UserProfile } from '../../../../packages/models' /* eslint-disable object-shorthand */
/* eslint-disable func-names */
import { UserProfile } from '@linux-odyssey/models'
export interface QuestProgress { export function questList() {
_id: string
started: number
completed: number
completedRate: number
failed: number
}
export function questList(): Promise<QuestProgress[]> {
return UserProfile.aggregate([ return UserProfile.aggregate([
{ {
$project: { $project: {
@@ -1,5 +1,5 @@
import { Command, Session } from '../../../../packages/models' import { Command, Session } from '@linux-odyssey/models'
import Pagination from './pagination' import Pagination from './pagination.js'
import { import {
CommandObject, CommandObject,
SessionDetail, SessionDetail,
@@ -1,7 +1,7 @@
import { HydratedDocument, Types } from 'mongoose' import { Session, User } from '@linux-odyssey/models'
import { Session, User, IUser, ISession } from '../../../../packages/models' import { Types } from 'mongoose'
import Pagination from './pagination' import Pagination from './pagination.js'
import { UserSessionDetail } from '../interface' import { IUser, UserSessionDetail } from '../interface.js'
function loginMethods(user: IUser): string[] { function loginMethods(user: IUser): string[] {
const methods: string[] = [] const methods: string[] = []
@@ -23,11 +23,7 @@ export function userCount() {
} }
export async function userList(pagination: Pagination) { export async function userList(pagination: Pagination) {
const users: { const users = await Session.aggregate([
userData: HydratedDocument<IUser>
count: number
lastActivityAt: Date
}[] = await Session.aggregate([
pagination.match('user'), pagination.match('user'),
{ {
$group: { $group: {
@@ -66,7 +62,7 @@ export async function userList(pagination: Pagination) {
export async function userDetail( export async function userDetail(
id: Types.ObjectId id: Types.ObjectId
): Promise<UserSessionDetail[]> { ): Promise<UserSessionDetail[]> {
const sessions: HydratedDocument<ISession>[] = await Session.find({ const sessions = await Session.find({
user: id, user: id,
}) })
return sessions.map((session): UserSessionDetail => { return sessions.map((session): UserSessionDetail => {
@@ -1,13 +1,16 @@
import { Router } from 'express' import { Router } from 'express'
import { homeController } from './controllers/index' import { homeController } from './controllers/index.js'
import { userListController, userDetailController } from './controllers/users' import {
import { questListController } from './controllers/quests' userListController,
import { commandListController } from './controllers/commands' userDetailController,
} from './controllers/users.js'
import { questListController } from './controllers/quests.js'
import { commandListController } from './controllers/commands.js'
import { import {
sessionDetailController, sessionDetailController,
sessionListController, sessionListController,
} from './controllers/sessions' } from './controllers/sessions.js'
const router = Router() const router = Router()
@@ -1,9 +1,11 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "src",
"outDir": "./dist" "outDir": "./dist"
}, },
"include": ["src", "../models"], "include": ["src"],
"references": [{ "path": "../models" }],
"ts-node": { "ts-node": {
"swc": true "swc": true
} }
+3 -10
View File
@@ -1,5 +1,3 @@
import { z } from 'zod'
export const passwordPolicy = { export const passwordPolicy = {
minLength: 8, minLength: 8,
minNumbers: 1, minNumbers: 1,
@@ -8,11 +6,6 @@ export const passwordPolicy = {
minSymbols: 0, minSymbols: 0,
} }
export const getSessionsRequestSchema = z.object({ export default {
questId: z.string().optional(), passwordPolicy,
status: z.enum(['active', 'inactive', 'finished']).optional(), }
limit: z.coerce.number().optional(),
order: z.enum(['asc', 'desc']).optional(),
})
export type GetSessionsRequest = z.infer<typeof getSessionsRequestSchema>
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@linux-odyssey/constants",
"type": "module",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"exports": "./dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL",
"scripts": {
"build": "tsc",
"dev": "tsc -w"
}
}
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff. Show more