mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Merge branch 'main' into game
This commit is contained in:
commit
2a868ead48
174 files changed
+6770
-3484
No files matched your search
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
.git
|
.git
|
||||||
*/dist
|
**/dist
|
||||||
|
**/tsconfig.tsbuildinfo
|
||||||
@@ -48,6 +48,18 @@ module.exports = {
|
|||||||
'no-use-before-define': 'off',
|
'no-use-before-define': 'off',
|
||||||
|
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
args: 'after-used',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
caughtErrors: 'all',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
destructuredArrayIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
// Allow missing file extensions
|
// Allow missing file extensions
|
||||||
'import/extensions': [
|
'import/extensions': [
|
||||||
|
|||||||
@@ -35,23 +35,3 @@ 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
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
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
|
||||||
@@ -12,23 +12,6 @@ jobs:
|
|||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
code_review:
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Code Review GPT
|
|
||||||
uses: mattzcarey/code-review-gpt@v0.1.4-alpha
|
|
||||||
with:
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
MODEL: 'gpt-3.5-turbo'
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: ci-checks
|
|
||||||
uses: ./.github/workflows/build.yml
|
uses: ./.github/workflows/build.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
Run mongoDB first:
|
Run mongoDB first:
|
||||||
|
|
||||||
docker compose -f docker-compose.dev.yml up -d db
|
docker compose up -d
|
||||||
|
|
||||||
The db will automatically restarts by Docker.
|
The db will automatically restarts by Docker.
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -6,8 +6,9 @@ COPY package.json yarn.lock tsconfig.json ./
|
|||||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||||
COPY packages/utils/package.json ./packages/utils/package.json
|
COPY packages/utils/package.json ./packages/utils/package.json
|
||||||
COPY packages/constants/package.json ./packages/constants/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
|
||||||
RUN yarn install
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
# Dev stage
|
# Dev stage
|
||||||
FROM base as dev
|
FROM base as dev
|
||||||
@@ -19,8 +20,8 @@ FROM base as build
|
|||||||
COPY packages/file-graph ./packages/file-graph
|
COPY packages/file-graph ./packages/file-graph
|
||||||
COPY packages/utils ./packages/utils
|
COPY packages/utils ./packages/utils
|
||||||
COPY packages/constants ./packages/constants
|
COPY packages/constants ./packages/constants
|
||||||
|
COPY packages/models ./packages/models
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
RUN yarn workspace app build:refs
|
|
||||||
RUN yarn workspace app build
|
RUN yarn workspace app build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
|
|||||||
+1
-1
@@ -8,6 +8,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+9
-6
@@ -2,15 +2,15 @@
|
|||||||
"name": "app",
|
"name": "app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:docker": "API_TARGET=http://backend:3000 vite",
|
"dev:docker": "API_TARGET=http://backend:3000 vite",
|
||||||
"build": "vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"build:refs": "tsc --build",
|
"type-check": "vue-tsc -b",
|
||||||
"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",
|
||||||
@@ -18,11 +18,13 @@
|
|||||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||||
"@linux-odyssey/constants": "*",
|
"@linux-odyssey/constants": "*",
|
||||||
"@linux-odyssey/file-graph": "*",
|
"@linux-odyssey/file-graph": "*",
|
||||||
|
"@linux-odyssey/models": "*",
|
||||||
"@linux-odyssey/utils": "*",
|
"@linux-odyssey/utils": "*",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"dompurify": "^3.0.6",
|
"dompurify": "^3.0.6",
|
||||||
"echarts": "^5.4.3",
|
|
||||||
"marked": "^10.0.0",
|
"marked": "^10.0.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
"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",
|
||||||
@@ -34,13 +36,14 @@
|
|||||||
"xterm-addon-fit": "^0.7.0"
|
"xterm-addon-fit": "^0.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/validator": "^13.11.9",
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
"@vue/compiler-sfc": "^3.3.13",
|
"@vue/compiler-sfc": "^3.3.13",
|
||||||
"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.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^4.5.2",
|
"vite": "^4.5.3",
|
||||||
"vue-tsc": "^1.8.25"
|
"vue-tsc": "^2.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export default {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
File renamed without changes.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
@@ -27,7 +27,7 @@ const password = ref('')
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const availableMethods = ref({})
|
const availableMethods = ref<{ [key: string]: boolean }>({})
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const res = await api.get('/auth/available-methods')
|
const res = await api.get('/auth/available-methods')
|
||||||
availableMethods.value = res.data
|
availableMethods.value = res.data
|
||||||
@@ -44,7 +44,7 @@ const success = () => {
|
|||||||
router.push({ name: 'map' })
|
router.push({ name: 'map' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = (err) => {
|
const error = (err: string) => {
|
||||||
errorMessage.value = err
|
errorMessage.value = err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +83,16 @@ 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 -->
|
||||||
@@ -114,10 +124,7 @@ const handleChange = () => {
|
|||||||
:icon="['fab', 'google']"
|
:icon="['fab', 'google']"
|
||||||
class="text-text-secondary px-3 text-lg"
|
class="text-text-secondary px-3 text-lg"
|
||||||
/>
|
/>
|
||||||
<span class="font-medium">
|
<span class="font-medium"> 以 Google 繼續 </span>
|
||||||
<!-- Continue with Google -->
|
|
||||||
以 Google 繼續
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
id="GitHubLogin"
|
id="GitHubLogin"
|
||||||
@@ -129,32 +136,17 @@ const handleChange = () => {
|
|||||||
:icon="['fab', 'github']"
|
:icon="['fab', 'github']"
|
||||||
class="text-text-secondary px-3 text-lg"
|
class="text-text-secondary px-3 text-lg"
|
||||||
/>
|
/>
|
||||||
<span class="font-medium">
|
<span class="font-medium"> 以 GitHub 繼續 </span>
|
||||||
<!-- Continue with GitHub -->
|
|
||||||
以 GitHub 繼續
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
<p
|
<p
|
||||||
id="or"
|
id="or"
|
||||||
class="text-text-secondary flex justify-center mt-3"
|
class="text-text-secondary flex justify-center mt-3"
|
||||||
v-if="type === 'login' || type === 'register'"
|
v-if="type === 'login' || type === 'register'"
|
||||||
>
|
>
|
||||||
<!-- or -->
|
|
||||||
或
|
或
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<!-- <input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
ref="usernameInput"
|
|
||||||
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
|
||||||
:placeholder="type === 'login' ? 'Email / Username' : 'Username'"
|
|
||||||
v-model="username"
|
|
||||||
required
|
|
||||||
@input="handleChange()"
|
|
||||||
autocomplete="username"
|
|
||||||
/> -->
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="username"
|
id="username"
|
||||||
@@ -176,17 +168,6 @@ const handleChange = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6" v-if="type === 'register'">
|
<div class="mb-6" v-if="type === 'register'">
|
||||||
<!-- <input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
ref="emailInput"
|
|
||||||
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
|
||||||
placeholder="Email"
|
|
||||||
v-model="email"
|
|
||||||
required
|
|
||||||
@input="handleChange()"
|
|
||||||
autocomplete="email"
|
|
||||||
/> -->
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
@@ -200,15 +181,6 @@ const handleChange = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6" v-if="type === 'login' || type === 'register'">
|
<div class="mb-6" v-if="type === 'login' || type === 'register'">
|
||||||
<!-- <input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
|
||||||
placeholder="Password"
|
|
||||||
v-model="password"
|
|
||||||
required
|
|
||||||
@input="handleChange()"
|
|
||||||
/> -->
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
@@ -231,46 +203,39 @@ const handleChange = () => {
|
|||||||
class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full"
|
class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<span v-if="type === 'login'">
|
<span v-if="type === 'login'"> 登入 </span>
|
||||||
<!-- Log In -->
|
<span v-else> 註冊 </span>
|
||||||
登入
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
<!-- Sign Up -->
|
|
||||||
註冊
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p id="SwitchLoginRegister" class="text-text flex justify-center mt-3">
|
<p id="SwitchLoginRegister" class="text-text flex justify-center mt-3">
|
||||||
<span id="CheckRegistered" v-if="type === 'register'">
|
<span id="CheckRegistered" v-if="type === 'register'">
|
||||||
<!-- Already have an account? -->
|
|
||||||
已經有帳號了嗎?
|
已經有帳號了嗎?
|
||||||
<RouterLink
|
<RouterLink
|
||||||
id="SwitchtoLogin"
|
id="SwitchtoLogin"
|
||||||
class="text-text-primary font-bold"
|
class="text-text-primary font-bold"
|
||||||
to="/login"
|
to="/login"
|
||||||
>
|
>
|
||||||
<u>
|
<u> 登入 </u>
|
||||||
<!-- Log in -->
|
|
||||||
登入
|
|
||||||
</u>
|
|
||||||
</RouterLink></span
|
</RouterLink></span
|
||||||
>
|
>
|
||||||
<span id="CheckRegistered" v-else-if="type === 'login'">
|
<span id="CheckRegistered" v-else-if="type === 'login'">
|
||||||
<!-- Don't have an account? -->
|
|
||||||
還沒有帳號嗎?
|
還沒有帳號嗎?
|
||||||
<RouterLink
|
<RouterLink
|
||||||
id="SwitchtoRegister"
|
id="SwitchtoRegister"
|
||||||
class="text-text-primary font-bold"
|
class="text-text-primary font-bold"
|
||||||
to="/register"
|
to="/register"
|
||||||
>
|
>
|
||||||
<u>
|
<u> 註冊 </u>
|
||||||
<!-- Sign up -->
|
|
||||||
註冊
|
|
||||||
</u>
|
|
||||||
</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>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
defineProps({
|
defineProps({
|
||||||
url: {
|
url: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
<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">
|
|
||||||
<!-- Survey -->
|
|
||||||
問卷
|
|
||||||
</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">
|
|
||||||
<!-- Solution -->
|
|
||||||
解答
|
|
||||||
</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">
|
|
||||||
<!-- Reset -->
|
|
||||||
重來
|
|
||||||
</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">
|
|
||||||
<!-- Continue -->
|
|
||||||
繼續
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
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('Failed to load map')
|
|
||||||
useToast().error('無法讀取地圖')
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const cw = ref(0)
|
const cw = ref(0)
|
||||||
const ch = ref(0)
|
const ch = ref(0)
|
||||||
const fallingCharArr = ref([])
|
const fallingCharArr = ref<
|
||||||
|
{ value: string; x: number; y: number; speed: number }[]
|
||||||
|
>([])
|
||||||
const fontSize = ref(16)
|
const fontSize = ref(16)
|
||||||
const maxColums = ref(0)
|
const maxColums = ref(0)
|
||||||
const charArr = ['0', '1', '', '', '']
|
const charArr = ['0', '1', '', '', '']
|
||||||
const ctx = ref(null)
|
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||||||
|
|
||||||
const randomInt = (min, max) => {
|
const randomInt = (min: number, max: number) => {
|
||||||
return Math.floor(Math.random() * (max - min) + min)
|
return Math.floor(Math.random() * (max - min) + min)
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomFloat = (min, max) => {
|
const randomFloat = (min: number, max: number) => {
|
||||||
return Math.random() * (max - min) + min
|
return Math.random() * (max - min) + min
|
||||||
}
|
}
|
||||||
|
|
||||||
const draw = (point) => {
|
const draw = (point: {
|
||||||
|
value: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
speed: number
|
||||||
|
}) => {
|
||||||
point.value = charArr[randomInt(0, charArr.length)].toUpperCase()
|
point.value = charArr[randomInt(0, charArr.length)].toUpperCase()
|
||||||
if (point.y < 0) point.speed = randomFloat(10, 20)
|
if (point.y < 0) point.speed = randomFloat(10, 20)
|
||||||
|
|
||||||
|
if (ctx.value === null) return
|
||||||
ctx.value.fillStyle = '#0F0'
|
ctx.value.fillStyle = '#0F0'
|
||||||
ctx.value.font = `${fontSize.value}px san-serif`
|
ctx.value.font = `${fontSize.value}px san-serif`
|
||||||
ctx.value.fillText(point.value, point.x, point.y)
|
ctx.value.fillText(point.value, point.x, point.y)
|
||||||
@@ -33,6 +41,7 @@ const draw = (point) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
|
if (ctx.value === null) return
|
||||||
ctx.value.fillStyle = 'rgba(0,0,0,0.05)'
|
ctx.value.fillStyle = 'rgba(0,0,0,0.05)'
|
||||||
ctx.value.fillRect(0, 0, cw.value, ch.value)
|
ctx.value.fillRect(0, 0, cw.value, ch.value)
|
||||||
|
|
||||||
@@ -48,7 +57,7 @@ onMounted(() => {
|
|||||||
ch.value = document.documentElement.clientHeight
|
ch.value = document.documentElement.clientHeight
|
||||||
maxColums.value = cw.value / fontSize.value
|
maxColums.value = cw.value / fontSize.value
|
||||||
|
|
||||||
const canvas = document.querySelector('canvas')
|
const canvas = document.querySelector('canvas') as HTMLCanvasElement
|
||||||
ctx.value = canvas.getContext('2d')
|
ctx.value = canvas.getContext('2d')
|
||||||
|
|
||||||
canvas.width = cw.value
|
canvas.width = cw.value
|
||||||
@@ -58,6 +67,8 @@ onMounted(() => {
|
|||||||
fallingCharArr.value.push({
|
fallingCharArr.value.push({
|
||||||
x: i * fontSize.value,
|
x: i * fontSize.value,
|
||||||
y: randomFloat(-500, 0),
|
y: randomFloat(-500, 0),
|
||||||
|
value: '',
|
||||||
|
speed: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
node: {
|
node: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -20,7 +20,21 @@ function nodeClass() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
|
<a href="#" :class="nodeClass()">
|
||||||
|
<font-awesome-icon
|
||||||
|
v-if="node.type === 'folder'"
|
||||||
|
: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" />
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
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('Failed to logout')
|
|
||||||
toast.error('登出失敗')
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
await loadUserProfile()
|
|
||||||
} catch (err) {
|
|
||||||
// toast.error('Failed to load user profile')
|
|
||||||
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,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, defineProps } from 'vue'
|
import { computed, defineProps } from 'vue'
|
||||||
import markdown2HTML from '../utils/markdown'
|
import markdown2HTML from '../utils/markdown'
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@ 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;
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { ref, defineProps, onMounted } from 'vue'
|
||||||
|
import { getQuest } from '../utils/api'
|
||||||
|
import MarkdownText from './MarkdownText.vue'
|
||||||
|
|
||||||
|
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 getQuest(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"
|
||||||
|
>×</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>
|
||||||
+231
-168
@@ -1,191 +1,254 @@
|
|||||||
<!-- eslint-disable no-console -->
|
<script setup lang="ts">
|
||||||
<script setup>
|
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 { use, init } from 'echarts/core'
|
|
||||||
import { GraphChart } from 'echarts/charts'
|
|
||||||
import { SVGRenderer } from 'echarts/renderers'
|
|
||||||
import {
|
|
||||||
TitleComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
DataZoomComponent,
|
|
||||||
} from 'echarts/components'
|
|
||||||
import { DAG } from '@linux-odyssey/utils'
|
import { DAG } from '@linux-odyssey/utils'
|
||||||
import api from '../utils/api'
|
import { getQuests } from '../utils/api'
|
||||||
import { NodeImage } from '../img/svg.js'
|
import useUserProfile from '../store/userProfile'
|
||||||
|
import QuestIntro from './QuestIntro.vue'
|
||||||
|
|
||||||
const marginX = 500
|
const store = useUserProfile()
|
||||||
const marginY = 300
|
|
||||||
const chartContainer = ref(null)
|
|
||||||
let chartInstance = null
|
|
||||||
const fullwidth = window.screen.width
|
|
||||||
|
|
||||||
use([
|
|
||||||
GraphChart,
|
|
||||||
SVGRenderer,
|
|
||||||
TitleComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
DataZoomComponent,
|
|
||||||
])
|
|
||||||
|
|
||||||
async function getQuests() {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/quests')
|
|
||||||
return res.data
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getProgress() {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/users/me')
|
|
||||||
return res.data.progress
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const genOption = (nodes, edges) => ({
|
|
||||||
renderer: 'svg',
|
|
||||||
tooltip: {},
|
|
||||||
animationDurationUpdate: 1500,
|
|
||||||
animationEasingUpdate: 'quinticInOut',
|
|
||||||
// dataZoom: [
|
|
||||||
// {
|
|
||||||
// type: 'slider', // This type means "slider data zoom" which provides a slider bar for zooming
|
|
||||||
// orient: 'vertical', // This makes the data zoom component vertical
|
|
||||||
// start: 0, // The starting percentage of the window out of the data extent
|
|
||||||
// end: 100, // The ending percentage of the window out of the data extent
|
|
||||||
// show: true,
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'graph',
|
|
||||||
layout: 'none',
|
|
||||||
symbol: () => {
|
|
||||||
return NodeImage
|
|
||||||
},
|
|
||||||
symbolSize: [fullwidth / 13, fullwidth / 30],
|
|
||||||
roam: 'move',
|
|
||||||
zoom: 1.5,
|
|
||||||
center: ['120%', '30%'],
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
fontSize: fullwidth / 90,
|
|
||||||
},
|
|
||||||
edgeSymbol: ['pin', 'arrow'],
|
|
||||||
edgeSymbolSize: [2, 15],
|
|
||||||
edgeLabel: {
|
|
||||||
fontSize: 25,
|
|
||||||
},
|
|
||||||
data: nodes,
|
|
||||||
// links: [],
|
|
||||||
links: edges,
|
|
||||||
lineStyle: {
|
|
||||||
width: 3,
|
|
||||||
color: 'gray',
|
|
||||||
opacity: 4,
|
|
||||||
curveness: 0.08,
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
color: (params) => {
|
|
||||||
const {
|
|
||||||
data: { completed, unlocked },
|
|
||||||
} = params
|
|
||||||
if (completed) {
|
|
||||||
return '#00ff00'
|
|
||||||
}
|
|
||||||
if (unlocked) {
|
|
||||||
return '#ADADB5'
|
|
||||||
}
|
|
||||||
return '#505050'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
function getOption(quests, progress) {
|
|
||||||
const dag = new DAG(quests)
|
|
||||||
|
|
||||||
const nodes = dag.getNodes().map((node) => ({
|
|
||||||
id: node._id,
|
|
||||||
name: node.title,
|
|
||||||
y: marginX * node.index - (marginX * dag.getLayer(node._id)) / 2,
|
|
||||||
x: marginY * node.layer * 2,
|
|
||||||
completed: progress[node._id]?.completed || false,
|
|
||||||
unlocked: node.requirements.every((req) => progress[req]?.completed),
|
|
||||||
}))
|
|
||||||
const lineAppearence = (node) => {
|
|
||||||
try {
|
|
||||||
const { unlocked } = node
|
|
||||||
return {
|
|
||||||
type: unlocked ? 'line' : 'dashed',
|
|
||||||
color: unlocked ? '#ADADB5' : '#454552',
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const edges = dag.getEdgesArray().map((edge) => ({
|
|
||||||
source: edge[0],
|
|
||||||
target: edge[1],
|
|
||||||
lineStyle: lineAppearence(nodes.find((node) => node.id === edge[1])),
|
|
||||||
}))
|
|
||||||
return genOption(nodes, edges)
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
function initChart(option) {
|
const svgWidth = ref(window.innerWidth)
|
||||||
chartInstance = init(chartContainer.value)
|
const svgHeight = ref(window.innerHeight)
|
||||||
chartInstance.setOption(option)
|
|
||||||
chartInstance.on('click', (params) => {
|
|
||||||
const {
|
|
||||||
data: { id, unlocked },
|
|
||||||
} = params
|
|
||||||
|
|
||||||
if (unlocked) {
|
const marginX = 120
|
||||||
router.push({ name: 'game', params: { questId: id } })
|
const marginY = 120
|
||||||
} else {
|
|
||||||
// toast.warning('You have not completed the previous quest yet!')
|
const nodeWidth = 160
|
||||||
toast.warning('你還沒完成前一個關卡!')
|
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 = {
|
||||||
|
source: Node
|
||||||
|
target: Node
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphData = ref<{ nodes: Node[]; edges: Edge[] }>({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStartX = ref(0)
|
||||||
|
const dragStartY = ref(0)
|
||||||
|
const offsetX = ref(0)
|
||||||
|
const offsetY = ref(0)
|
||||||
|
|
||||||
|
function startDrag(event: MouseEvent) {
|
||||||
|
isDragging.value = true
|
||||||
|
dragStartX.value = event.clientX - offsetX.value
|
||||||
|
dragStartY.value = event.clientY - offsetY.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(event: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
offsetX.value = event.clientX - dragStartX.value
|
||||||
|
offsetY.value = event.clientY - dragStartY.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeGraphData() {
|
||||||
|
if (!store.progress) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quests = await getQuests()
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleNodeClick(node: Node) {
|
||||||
|
opened.value = node
|
||||||
|
}
|
||||||
|
function closeIntro(close: boolean) {
|
||||||
|
if (close) {
|
||||||
|
opened.value = null
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const quests = await getQuests()
|
await store.loadUserProfile()
|
||||||
const progress = await getProgress()
|
await computeGraphData()
|
||||||
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">
|
||||||
<img
|
<div
|
||||||
src="../img/catelogbg.svg"
|
class="flex place-content-center w-full bg-catelogbg bg-cover bg-scroll"
|
||||||
alt="bg"
|
>
|
||||||
class="p-2 stroke-2 scale-y-120 justify-center items-center"
|
|
||||||
/>
|
|
||||||
<h1
|
<h1
|
||||||
class="p-4 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
|
class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
|
||||||
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
|
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
|
||||||
>
|
>
|
||||||
<!-- Get through your linux journey! -->
|
|
||||||
踏上你的Linux冒險之旅吧!
|
踏上你的Linux冒險之旅吧!
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex flex-wrap absolute w-full h-full z-1">
|
<svg
|
||||||
<div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
|
: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>
|
||||||
+2
-8
@@ -1,13 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
|
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
|
||||||
<h1 id="CommandBook" class="inline text-text-primary font-xl">
|
<h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1>
|
||||||
<!-- Command Cheatsheets -->
|
|
||||||
指令之書
|
|
||||||
</h1>
|
|
||||||
<div id="cheatsheets">
|
<div id="cheatsheets">
|
||||||
<p class="text-text-secondary">
|
<p class="text-text-secondary">-- --指令列表-- --</p>
|
||||||
<!-- --Command List-- -->
|
|
||||||
-- --指令列表-- --
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
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,
|
||||||
@@ -22,11 +24,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)
|
||||||
}
|
}
|
||||||
@@ -47,7 +49,6 @@ setTimeout(() => {
|
|||||||
id="QuestCompleted"
|
id="QuestCompleted"
|
||||||
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
|
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
|
||||||
>
|
>
|
||||||
<!-- Quest Completed! -->
|
|
||||||
關卡完成!
|
關卡完成!
|
||||||
</h2>
|
</h2>
|
||||||
<div class="z-10 right-1/2 top-1/2" @animationend="defineComponent">
|
<div class="z-10 right-1/2 top-1/2" @animationend="defineComponent">
|
||||||
@@ -61,17 +62,6 @@ 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"
|
||||||
@@ -80,6 +70,21 @@ 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>
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<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>
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import sessionStore from '../store/session'
|
import useSession from '../../store/session'
|
||||||
import MarkdownText from './MarkdownText.vue'
|
import MarkdownText from '../MarkdownText.vue'
|
||||||
|
|
||||||
|
const sessionStore = useSession()
|
||||||
const current = ref(-1)
|
const current = ref(-1)
|
||||||
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, () => {
|
||||||
|
current.value = length.value - 1
|
||||||
|
})
|
||||||
|
|
||||||
const left = () => {
|
const left = () => {
|
||||||
if (current.value > 0) {
|
if (current.value > 0) {
|
||||||
@@ -19,7 +24,7 @@ const left = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const right = () => {
|
const right = () => {
|
||||||
if (current.value < sessionStore.session.hints.length - 1) {
|
if (current.value < length.value - 1) {
|
||||||
current.value += 1
|
current.value += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,16 +32,12 @@ const right = () => {
|
|||||||
|
|
||||||
<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 class="inline text-text w-1/2 font-xl p-2 m-1">說明</h1>
|
||||||
<!-- 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"
|
||||||
@@ -53,13 +54,13 @@ const right = () => {
|
|||||||
<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">
|
||||||
{{ sessionStore.session.hints.length }}
|
{{ 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="current === sessionStore.session.hints.length - 1"
|
:disabled="disabled"
|
||||||
>
|
>
|
||||||
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
|
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
|
||||||
</button>
|
</button>
|
||||||
@@ -68,8 +69,8 @@ const right = () => {
|
|||||||
<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-for="response in sessionStore.session.responses[current]"
|
v-for="response in sessionStore.session?.responses[current]"
|
||||||
:key="response"
|
:key="response.type"
|
||||||
class="text-text font-xl whitespace-pre-wrap"
|
class="text-text font-xl whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
<div v-for="content in response.content" :key="content">
|
<div v-for="content in response.content" :key="content">
|
||||||
@@ -77,7 +78,7 @@ const right = () => {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-for="hint in sessionStore.session.hints[current]"
|
v-for="hint in sessionStore.session?.hints[current]"
|
||||||
:key="hint"
|
:key="hint"
|
||||||
class="text-text-primary font-xl whitespace-pre-wrap"
|
class="text-text-primary font-xl whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import MarkdownText from './MarkdownText.vue'
|
import MarkdownText from '../MarkdownText.vue'
|
||||||
import sessionStore from '../store/session'
|
import useSession from '../../store/session'
|
||||||
|
import { Task } from '../../types'
|
||||||
|
|
||||||
|
const sessionStore = useSession()
|
||||||
const tasks = computed(() => {
|
const tasks = computed(() => {
|
||||||
return sessionStore.session.tasks.map((task) => {
|
return sessionStore.session?.tasks.map((task: 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 {
|
||||||
@@ -29,10 +31,7 @@ const tasks = computed(() => {
|
|||||||
:content="sessionStore.quest.instruction"
|
:content="sessionStore.quest.instruction"
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<p id="tasks" class="text-text">
|
<p id="tasks" class="text-text">任務:</p>
|
||||||
<!-- Tasks: -->
|
|
||||||
任務:
|
|
||||||
</p>
|
|
||||||
<ul v-if="sessionStore.session">
|
<ul v-if="sessionStore.session">
|
||||||
<li v-for="task in tasks" :key="task.id">
|
<li v-for="task in tasks" :key="task.id">
|
||||||
<MarkdownText :class="task.class" :content="task.content" />
|
<MarkdownText :class="task.class" :content="task.content" />
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<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>
|
||||||
@@ -1,25 +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>
|
<template>
|
||||||
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
|
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
|
||||||
<button id="Terminal" class="text-text h-[5%]">
|
<button id="Terminal" class="text-text h-[5%]">終端機</button>
|
||||||
<!-- Terminal -->
|
|
||||||
終端機
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
id="terminal"
|
id="terminal"
|
||||||
ref="terminal"
|
ref="terminal"
|
||||||
class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
|
class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useTerminal } from '../store/session'
|
|
||||||
|
|
||||||
const terminal = ref(null)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const term = useTerminal()
|
|
||||||
term.mount(terminal.value)
|
|
||||||
window.addEventListener('resize', () => term.resizeScreen())
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import FileNode from './FileNode.vue'
|
import FileNode from '../FileNode.vue'
|
||||||
import sessionStore from '../store/session.js'
|
import useSession from '../../store/session'
|
||||||
|
|
||||||
|
const sessionStore = useSession()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -8,6 +10,7 @@ import sessionStore from '../store/session.js'
|
|||||||
<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"
|
||||||
/>
|
/>
|
||||||
@@ -45,14 +48,14 @@ import sessionStore from '../store/session.js'
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 50%;
|
right: 50%;
|
||||||
border-top: 1px solid #00ff00;
|
border-top: 2px 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: 1px solid #00ff00;
|
border-left: 2px solid #00ff00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree li:only-child::after,
|
.tree li:only-child::after,
|
||||||
@@ -70,7 +73,7 @@ import sessionStore from '../store/session.js'
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tree li:last-child::before {
|
.tree li:last-child::before {
|
||||||
border-right: 1px solid #00ff00;
|
border-right: 2px 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;
|
||||||
@@ -86,7 +89,7 @@ import sessionStore from '../store/session.js'
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
border-left: 1px solid #00ff00;
|
border-left: 2px solid #00ff00;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
@@ -97,7 +100,7 @@ import sessionStore from '../store/session.js'
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: arial, verdana, tahoma;
|
font-family: arial, verdana, tahoma;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -109,18 +112,10 @@ import sessionStore from '../store/session.js'
|
|||||||
-moz-transition: all 0.5s;
|
-moz-transition: all 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.discovered {
|
.tree li a.pwd {
|
||||||
background: #526b4e;
|
border: 3px solid #00ff00;
|
||||||
color: #000;
|
background: #008080;
|
||||||
border: 1px solid #94a0b4;
|
font-weight: bold;
|
||||||
}
|
font-size: 13px;
|
||||||
|
|
||||||
ul.discovered,
|
|
||||||
li.discovered {
|
|
||||||
border-color: #94a0b4;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.pwd {
|
|
||||||
box-shadow: 0 0 40px rgba(255, 255, 0, 20);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+8
-4
@@ -1,19 +1,21 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import TreeChart from './TreeChart.vue'
|
import TreeChart from './TreeChart.vue'
|
||||||
|
|
||||||
const treeRef = ref(null)
|
const treeRef = ref<HTMLElement | null>(null)
|
||||||
let pos = { top: 0, left: 0, x: 0, y: 0 }
|
let pos = { top: 0, left: 0, x: 0, y: 0 }
|
||||||
|
|
||||||
function mouseMoveHandler(e) {
|
function mouseMoveHandler(e: MouseEvent) {
|
||||||
const dx = e.clientX - pos.x
|
const dx = e.clientX - pos.x
|
||||||
const dy = e.clientY - pos.y
|
const dy = e.clientY - pos.y
|
||||||
|
|
||||||
|
if (treeRef.value === null) return
|
||||||
treeRef.value.scrollTop = pos.top - dy
|
treeRef.value.scrollTop = pos.top - dy
|
||||||
treeRef.value.scrollLeft = pos.left - dx
|
treeRef.value.scrollLeft = pos.left - dx
|
||||||
}
|
}
|
||||||
|
|
||||||
function mouseUpHandler() {
|
function mouseUpHandler() {
|
||||||
|
if (treeRef.value === null) return
|
||||||
treeRef.value.style.cursor = 'grab'
|
treeRef.value.style.cursor = 'grab'
|
||||||
treeRef.value.style.removeProperty('user-select')
|
treeRef.value.style.removeProperty('user-select')
|
||||||
|
|
||||||
@@ -21,7 +23,8 @@ function mouseUpHandler() {
|
|||||||
document.removeEventListener('mouseup', mouseUpHandler)
|
document.removeEventListener('mouseup', mouseUpHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mouseDownHandler(e) {
|
function mouseDownHandler(e: MouseEvent) {
|
||||||
|
if (treeRef.value === null) return
|
||||||
treeRef.value.style.cursor = 'grabbing'
|
treeRef.value.style.cursor = 'grabbing'
|
||||||
treeRef.value.style.userSelect = 'none'
|
treeRef.value.style.userSelect = 'none'
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@ function mouseDownHandler(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (treeRef.value === null) return
|
||||||
treeRef.value.style.cursor = 'grab'
|
treeRef.value.style.cursor = 'grab'
|
||||||
treeRef.value.addEventListener('mousedown', mouseDownHandler)
|
treeRef.value.addEventListener('mousedown', mouseDownHandler)
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { logout } from '../../utils/auth'
|
||||||
|
import { surveyUrl } from '../../config'
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<a
|
||||||
|
title="Survey"
|
||||||
|
:href="surveyUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||||
|
>
|
||||||
|
<font-awesome-icon
|
||||||
|
:icon="['fas', 'file-invoice']"
|
||||||
|
class="text-text-primary inline"
|
||||||
|
/>
|
||||||
|
<span class="text-text sm:hidden px-2">Survey</span>
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<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.
File renamed without changes.
@@ -3,27 +3,30 @@ import { library } from '@fortawesome/fontawesome-svg-core'
|
|||||||
|
|
||||||
/* import specific icons */
|
/* import specific icons */
|
||||||
import {
|
import {
|
||||||
faBars,
|
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faList,
|
|
||||||
faGear,
|
|
||||||
faTriangleExclamation,
|
|
||||||
faExpand,
|
|
||||||
faTerminal,
|
|
||||||
faArrowRotateLeft,
|
|
||||||
faArrowRightFromBracket,
|
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowRightFromBracket,
|
||||||
|
faArrowRotateLeft,
|
||||||
|
faBars,
|
||||||
faBug,
|
faBug,
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faMap,
|
|
||||||
faTrophy,
|
|
||||||
faFileInvoice,
|
|
||||||
faEnvelope,
|
faEnvelope,
|
||||||
|
faExpand,
|
||||||
|
faFileInvoice,
|
||||||
|
faGear,
|
||||||
|
faList,
|
||||||
|
faMap,
|
||||||
|
faPlay,
|
||||||
|
faTerminal,
|
||||||
|
faTriangleExclamation,
|
||||||
|
faTrophy,
|
||||||
} 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,
|
||||||
@@ -33,26 +36,29 @@ import {
|
|||||||
|
|
||||||
/* add icons to the library */
|
/* add icons to the library */
|
||||||
library.add(
|
library.add(
|
||||||
faBars,
|
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faList,
|
|
||||||
faGear,
|
|
||||||
faTriangleExclamation,
|
|
||||||
faExpand,
|
|
||||||
faTerminal,
|
|
||||||
faArrowRotateLeft,
|
|
||||||
faArrowRightFromBracket,
|
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowRightFromBracket,
|
||||||
|
faArrowRotateLeft,
|
||||||
|
faBars,
|
||||||
faBug,
|
faBug,
|
||||||
faMap,
|
|
||||||
faTrophy,
|
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faLightbulb,
|
faEnvelope,
|
||||||
|
faExpand,
|
||||||
|
faFileInvoice,
|
||||||
|
faGear,
|
||||||
|
faList,
|
||||||
|
faMap,
|
||||||
|
faPlay,
|
||||||
|
faTerminal,
|
||||||
|
faTriangleExclamation,
|
||||||
|
faTrophy,
|
||||||
faCircleQuestion,
|
faCircleQuestion,
|
||||||
faCircleRight,
|
faCircleRight,
|
||||||
faGithub,
|
faFile,
|
||||||
faGoogle,
|
faFolder,
|
||||||
faFileInvoice,
|
faLightbulb,
|
||||||
faFacebook,
|
faFacebook,
|
||||||
faEnvelope
|
faGithub,
|
||||||
|
faGoogle
|
||||||
)
|
)
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
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'
|
||||||
@@ -8,16 +10,24 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
const loggedIn = await isLoggedIn()
|
const loggedIn = await isLoggedIn()
|
||||||
// Check if the route requires authentication
|
// Check if the route requires authentication
|
||||||
if (to.meta.requiresAuth && !loggedIn) {
|
if (to.meta.requiresAuth && !loggedIn) {
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { reactive } from 'vue'
|
|
||||||
import { useToast } from 'vue-toastification'
|
|
||||||
import { FileGraph } from '@linux-odyssey/file-graph'
|
|
||||||
import api from '../utils/api'
|
|
||||||
import Socket from '../utils/socket'
|
|
||||||
import SocketTerminal from '../utils/terminal'
|
|
||||||
import { LoadQuestError, LoadSessionError } from '../utils/errors'
|
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
function newSession() {
|
|
||||||
const graph = new FileGraph({
|
|
||||||
path: '/',
|
|
||||||
type: 'folder',
|
|
||||||
discovered: false,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
status: 'inactive',
|
|
||||||
graph,
|
|
||||||
pwd: '',
|
|
||||||
hints: [],
|
|
||||||
tasks: [],
|
|
||||||
responses: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = reactive({
|
|
||||||
session: newSession(),
|
|
||||||
questId: '',
|
|
||||||
quest: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const socket = new Socket()
|
|
||||||
const term = new SocketTerminal(40, 80)
|
|
||||||
|
|
||||||
function setQuest(questId) {
|
|
||||||
return api.get(`/quests/${questId}`).then((res) => {
|
|
||||||
store.questId = res.data._id
|
|
||||||
store.quest = res.data
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setSession(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) {
|
|
||||||
console.error(err)
|
|
||||||
toast.error(err.message)
|
|
||||||
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) {
|
|
||||||
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) {
|
|
||||||
if (event.discover) {
|
|
||||||
store.session.graph.discover(event.discover)
|
|
||||||
}
|
|
||||||
if (event.pwd) {
|
|
||||||
store.session.pwd = event.pwd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function newResponse(response) {
|
|
||||||
console.log('newResponse', response, 'session', store.session)
|
|
||||||
store.session.responses.push(response.responses)
|
|
||||||
store.session.hints.push(response.hints)
|
|
||||||
store.session.tasks = response.tasks
|
|
||||||
store.session.status = response.status
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTerminal() {
|
|
||||||
return term
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reset() {
|
|
||||||
socket.reset()
|
|
||||||
term.reset()
|
|
||||||
store.session = newSession()
|
|
||||||
store.quest = null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function init(questId) {
|
|
||||||
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) => {
|
|
||||||
term.write(data)
|
|
||||||
})
|
|
||||||
term.onData((data) => {
|
|
||||||
socket.emit('terminal', data)
|
|
||||||
})
|
|
||||||
socket.on('graph', (event) => {
|
|
||||||
updateGraph(event)
|
|
||||||
})
|
|
||||||
socket.on('response', (response) => {
|
|
||||||
newResponse(response)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setup()
|
|
||||||
|
|
||||||
export default store
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
|
||||||
|
import { IQuest } from '@linux-odyssey/models'
|
||||||
|
import { createSession, getActiveSession, getQuest } from '../utils/api'
|
||||||
|
import Socket from '../utils/socket'
|
||||||
|
import SocketTerminal from '../utils/terminal'
|
||||||
|
import { Session, StageResponse } from '../types'
|
||||||
|
|
||||||
|
const socket = new Socket()
|
||||||
|
const term = new SocketTerminal(40, 80)
|
||||||
|
let hasSetup = false
|
||||||
|
|
||||||
|
interface Store {
|
||||||
|
session: Session | null
|
||||||
|
questId: string
|
||||||
|
quest: IQuest | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSession = defineStore('session', {
|
||||||
|
state: (): Store => ({
|
||||||
|
session: null,
|
||||||
|
questId: '',
|
||||||
|
quest: null,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async setQuest(questId: string) {
|
||||||
|
this.quest = await getQuest(questId)
|
||||||
|
this.questId = questId
|
||||||
|
},
|
||||||
|
async setSession(session: Session) {
|
||||||
|
this.session = session
|
||||||
|
this.session.graph = new FileGraph(session.graph)
|
||||||
|
term.reset()
|
||||||
|
await socket.connect(session)
|
||||||
|
term.focus()
|
||||||
|
},
|
||||||
|
async createSession() {
|
||||||
|
const session = await createSession(this.questId)
|
||||||
|
await this.setSession(session)
|
||||||
|
socket.emit('terminal', 'echo start\n')
|
||||||
|
},
|
||||||
|
async getActiveSession() {
|
||||||
|
const session = await getActiveSession(this.questId)
|
||||||
|
if (session) {
|
||||||
|
this.setSession(session)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateGraph(event: { discover?: FileObject[]; pwd?: string }) {
|
||||||
|
if (!this.session) return
|
||||||
|
if (event.discover) {
|
||||||
|
this.session.graph.discover(event.discover)
|
||||||
|
}
|
||||||
|
if (event.pwd) {
|
||||||
|
this.session.pwd = event.pwd
|
||||||
|
}
|
||||||
|
},
|
||||||
|
newResponse(response: StageResponse) {
|
||||||
|
if (!this.session) return
|
||||||
|
this.session.responses.push(response.responses)
|
||||||
|
this.session.hints.push(response.hints)
|
||||||
|
if (response.tasks) {
|
||||||
|
this.session.tasks = response.tasks
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
response.status === 'finished' &&
|
||||||
|
this.session.status !== 'finished'
|
||||||
|
) {
|
||||||
|
this.finish()
|
||||||
|
}
|
||||||
|
this.session.status = response.status
|
||||||
|
},
|
||||||
|
finish() {},
|
||||||
|
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: { discover: FileObject[]; pwd: string }) => {
|
||||||
|
this.updateGraph(event)
|
||||||
|
})
|
||||||
|
socket.on('response', (response: StageResponse) => {
|
||||||
|
this.newResponse(response)
|
||||||
|
})
|
||||||
|
hasSetup = true
|
||||||
|
},
|
||||||
|
setStatus(status: string) {
|
||||||
|
if (!this.session) return
|
||||||
|
this.session.status = status
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useTerminal() {
|
||||||
|
return term
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSession
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { reactive } from 'vue'
|
|
||||||
import api from '../utils/api'
|
|
||||||
|
|
||||||
const store = reactive({
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
progress: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { getUserProfile } from '../utils/api'
|
||||||
|
|
||||||
|
interface IProgress {
|
||||||
|
quest: string
|
||||||
|
sessions: string[]
|
||||||
|
completed: boolean
|
||||||
|
startedAt: Date
|
||||||
|
finishedAt?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
progress: Record<string, IProgress>
|
||||||
|
}
|
||||||
|
|
||||||
|
const useUserProfile = defineStore('userProfile', {
|
||||||
|
state: (): UserProfile => ({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
progress: {},
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async loadUserProfile() {
|
||||||
|
const res = await getUserProfile()
|
||||||
|
this.$patch(res)
|
||||||
|
},
|
||||||
|
async resetUserProfile() {
|
||||||
|
this.$reset()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default useUserProfile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { FileGraph } from '@linux-odyssey/file-graph'
|
||||||
|
// eslint-disable-next-line import/no-relative-packages
|
||||||
|
import { IResponse } from '@linux-odyssey/models'
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageResponse {
|
||||||
|
status: string
|
||||||
|
hints: string[]
|
||||||
|
tasks: Task[]
|
||||||
|
responses: IResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
_id: string
|
||||||
|
status: string
|
||||||
|
graph: FileGraph
|
||||||
|
pwd: string
|
||||||
|
hints: string[][]
|
||||||
|
tasks: Task[]
|
||||||
|
responses: IResponse[][]
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: '/api/v1',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
withCredentials: true,
|
|
||||||
})
|
|
||||||
export default api
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { IQuest } from '@linux-odyssey/models'
|
||||||
|
import { GetSessionsRequest } from '@linux-odyssey/constants'
|
||||||
|
import { Session } from '../types'
|
||||||
|
import { UserProfile } from '../store/userProfile'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
export default api
|
||||||
|
|
||||||
|
export async function getUserProfile(): Promise<UserProfile> {
|
||||||
|
const res = await api.get<UserProfile>('/users/me')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuest(id: string): Promise<IQuest> {
|
||||||
|
const res = await api.get<IQuest>(`/quests/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(questId: string): Promise<Session> {
|
||||||
|
const res = await api.post<Session>('/sessions', { questId })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessions(
|
||||||
|
request: GetSessionsRequest
|
||||||
|
): Promise<Session[]> {
|
||||||
|
const res = await api.get<Session[]>('/sessions', { params: request })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveSession(
|
||||||
|
questId: string
|
||||||
|
): Promise<Session | null> {
|
||||||
|
const res = await api.get<Session | null>('/sessions/active', {
|
||||||
|
params: { questId },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuests(): Promise<IQuest[]> {
|
||||||
|
const res = await api.get<IQuest[]>('/quests')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
@@ -12,7 +12,12 @@ export async function isLoggedIn() {
|
|||||||
return loggedIn
|
return loggedIn
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRequestError(err) {
|
function handleRequestError(err: {
|
||||||
|
response: {
|
||||||
|
data?: any
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
}) {
|
||||||
if (!err.response) throw err // Not an axios error
|
if (!err.response) throw err // Not an axios error
|
||||||
const { status } = err.response
|
const { status } = err.response
|
||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
@@ -41,14 +46,14 @@ function handleRequestError(err) {
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
export const login = (username, password) => {
|
export const login = (username: string, password: string) => {
|
||||||
return api
|
return api
|
||||||
.post('/auth/login', { username, password })
|
.post('/auth/login', { username, password })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(handleRequestError)
|
.catch(handleRequestError)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const register = (username, email, password) => {
|
export const register = (username: string, email: string, password: string) => {
|
||||||
return api
|
return api
|
||||||
.post('/auth/register', {
|
.post('/auth/register', {
|
||||||
username,
|
username,
|
||||||
@@ -59,7 +64,7 @@ export const register = (username, email, password) => {
|
|||||||
.catch(handleRequestError)
|
.catch(handleRequestError)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkUsername = async (username) => {
|
export const checkUsername = async (username: string) => {
|
||||||
return api
|
return api
|
||||||
.get('/auth/check-username', {
|
.get('/auth/check-username', {
|
||||||
params: { username },
|
params: { username },
|
||||||
@@ -68,7 +73,7 @@ export const checkUsername = async (username) => {
|
|||||||
.catch(handleRequestError)
|
.catch(handleRequestError)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chooseUsername = async (username) => {
|
export const chooseUsername = async (username: string) => {
|
||||||
return api
|
return api
|
||||||
.post('/auth/register-from-session', { username })
|
.post('/auth/register-from-session', { username })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
@@ -1,27 +1,28 @@
|
|||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
export class TooManyRequestsError extends Error {
|
export class TooManyRequestsError extends Error {
|
||||||
constructor(message) {
|
constructor(message: string) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'TooManyRequestsError'
|
this.name = 'TooManyRequestsError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ValidationError extends Error {
|
export class ValidationError extends Error {
|
||||||
constructor(message) {
|
constructor(message: string) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'ValidationError'
|
this.name = 'ValidationError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UnauthorizedError extends Error {
|
export class UnauthorizedError extends Error {
|
||||||
constructor(message) {
|
constructor(message: string) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'UnauthorizedError'
|
this.name = 'UnauthorizedError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoadQuestError extends Error {
|
export class LoadQuestError extends Error {
|
||||||
constructor(message, questId) {
|
questId: string
|
||||||
|
constructor(message: string, questId: string) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'LoadQuestError'
|
this.name = 'LoadQuestError'
|
||||||
this.questId = questId
|
this.questId = questId
|
||||||
@@ -29,7 +30,8 @@ export class LoadQuestError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LoadSessionError extends Error {
|
export class LoadSessionError extends Error {
|
||||||
constructor(message, questId) {
|
questId: string
|
||||||
|
constructor(message: string, questId: string) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'LoadSessionError'
|
this.name = 'LoadSessionError'
|
||||||
this.questId = questId
|
this.questId = questId
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
export default function markdown2HTML(markdown) {
|
export default function markdown2HTML(markdown: string) {
|
||||||
const html = marked.parse(markdown)
|
const html = marked.parse(markdown)
|
||||||
return DOMPurify.sanitize(html)
|
return DOMPurify.sanitize(html)
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import io from 'socket.io-client'
|
import { io, Socket } from 'socket.io-client'
|
||||||
|
import { Session } from '../types'
|
||||||
|
|
||||||
|
type SocketCallback = (data: any) => void
|
||||||
|
|
||||||
class SocketWrapper {
|
class SocketWrapper {
|
||||||
|
socket: Socket | null
|
||||||
|
listeners: { event: string; callback: SocketCallback }[]
|
||||||
constructor() {
|
constructor() {
|
||||||
this.socket = null
|
this.socket = null
|
||||||
this.listeners = []
|
this.listeners = []
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(session) {
|
connect(session: Session) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
this.disconnect()
|
this.disconnect()
|
||||||
|
|
||||||
this.socket = io('', {
|
this.socket = io('', {
|
||||||
@@ -28,7 +33,7 @@ class SocketWrapper {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event, callback) {
|
on(event: string, callback: SocketCallback) {
|
||||||
this.listeners.push({ event, callback })
|
this.listeners.push({ event, callback })
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.on(event, callback)
|
this.socket.on(event, callback)
|
||||||
@@ -36,12 +41,14 @@ class SocketWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindListeners() {
|
bindListeners() {
|
||||||
this.listeners.forEach(({ event, callback }) => {
|
this.listeners.forEach(
|
||||||
this.socket.on(event, callback)
|
({ event, callback }: { event: string; callback: SocketCallback }) => {
|
||||||
})
|
if (this.socket !== null) this.socket.on(event, callback)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(event, data) {
|
emit(event: string, data: any) {
|
||||||
if (!this.socket) {
|
if (!this.socket) {
|
||||||
console.warn('Socket is not connected!')
|
console.warn('Socket is not connected!')
|
||||||
return
|
return
|
||||||
@@ -3,9 +3,11 @@ import { FitAddon } from 'xterm-addon-fit'
|
|||||||
import 'xterm/css/xterm.css'
|
import 'xterm/css/xterm.css'
|
||||||
|
|
||||||
class SocketTerminal {
|
class SocketTerminal {
|
||||||
constructor(rows, cols) {
|
private term: Terminal
|
||||||
|
private fitAddon: FitAddon
|
||||||
|
|
||||||
|
constructor(rows: number, cols: number) {
|
||||||
this.term = new Terminal({
|
this.term = new Terminal({
|
||||||
rendererType: 'canvas', // 渲染类型
|
|
||||||
rows, // 行数
|
rows, // 行数
|
||||||
cols, // 不指定行数,自动回车后光标从下一行开始
|
cols, // 不指定行数,自动回车后光标从下一行开始
|
||||||
convertEol: true, // 启用时,光标将设置为下一行的开头
|
convertEol: true, // 启用时,光标将设置为下一行的开头
|
||||||
@@ -13,9 +15,11 @@ class SocketTerminal {
|
|||||||
disableStdin: false, // 是否应禁用输入
|
disableStdin: false, // 是否应禁用输入
|
||||||
// cursorStyle: "underline", //光标样式
|
// cursorStyle: "underline", //光标样式
|
||||||
cursorBlink: true, // 光标闪烁
|
cursorBlink: true, // 光标闪烁
|
||||||
|
theme: {
|
||||||
foreground: '#ECECEC', // 字体
|
foreground: '#ECECEC', // 字体
|
||||||
background: '#000000', // 背景色
|
background: '#000000', // 背景色
|
||||||
cursor: 'help', // 设置光标
|
cursor: 'help', // 设置光标
|
||||||
|
},
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 'normal',
|
fontWeight: 'normal',
|
||||||
})
|
})
|
||||||
@@ -24,29 +28,28 @@ class SocketTerminal {
|
|||||||
this.fitAddon.fit()
|
this.fitAddon.fit()
|
||||||
}
|
}
|
||||||
|
|
||||||
write(message) {
|
write(message: string): void {
|
||||||
this.term.write(message)
|
this.term.write(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
onData(callback) {
|
onData(callback: any): void {
|
||||||
this.term.onData(callback)
|
this.term.onData(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
focus() {
|
focus(): void {
|
||||||
this.term.focus()
|
this.term.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
resizeScreen() {
|
resizeScreen(): void {
|
||||||
this.fitAddon.fit()
|
this.fitAddon.fit()
|
||||||
}
|
}
|
||||||
|
|
||||||
mount(reference) {
|
mount(reference: HTMLElement): void {
|
||||||
this.term.open(reference)
|
this.term.open(reference)
|
||||||
this.term.focus()
|
|
||||||
this.resizeScreen()
|
this.resizeScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset(): void {
|
||||||
this.term.reset()
|
this.term.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { isValidUsername } from '@linux-odyssey/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'
|
||||||
import { checkUsername, chooseUsername } from '../utils/auth'
|
import { checkUsername, chooseUsername } from '../utils/auth'
|
||||||
|
|
||||||
async function check({ username, error }) {
|
async function check({
|
||||||
|
username,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
username: string
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
error: (msg: string) => void
|
||||||
|
}) {
|
||||||
if (username) {
|
if (username) {
|
||||||
if (!isValidUsername(username)) {
|
if (!isValidUsername(username)) {
|
||||||
// error('Invalid username.')
|
|
||||||
error('無效的帳號名稱')
|
error('無效的帳號名稱')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -16,7 +22,6 @@ async function check({ username, error }) {
|
|||||||
await checkUsername(username)
|
await checkUsername(username)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
// error('Too many requests. Try again in 2 minutes.')
|
|
||||||
error('太多請求,兩分鐘後再試一次。')
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -24,19 +29,26 @@ async function check({ username, error }) {
|
|||||||
error(err.message)
|
error(err.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// error('Something went wrong. Please try again later.')
|
|
||||||
error('出了點問題,請再試一次。')
|
error('出了點問題,請再試一次。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit({ username, success, error }) {
|
async function handleSubmit({
|
||||||
|
username,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
username: string
|
||||||
|
success: () => void
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
error: (msg: string) => void
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
await chooseUsername(username)
|
await chooseUsername(username)
|
||||||
success()
|
success()
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
// error('Too many requests. Try again in 2 minutes.')
|
|
||||||
error('太多請求,兩分鐘後再試一次。')
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-40
@@ -1,19 +1,30 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { useToast } from 'vue-toastification'
|
import useSession from '../store/session'
|
||||||
import sessionStore, { init } from '../store/session'
|
import useUserProfile from '../store/userProfile'
|
||||||
import GameHeaderPart from '../components/GameHeaderPart.vue'
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
// import CommandlistPart from '../components/CommandlistPart.vue'
|
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||||
import HintPart from '../components/HintPart.vue'
|
import DescriptionPart from '../components/game/DescriptionPart.vue'
|
||||||
import QuestPart from '../components/QuestPart.vue'
|
import QuestPart from '../components/game/QuestPart.vue'
|
||||||
import TerminalPart from '../components/TerminalPart.vue'
|
import TerminalPart from '../components/game/TerminalPart.vue'
|
||||||
import VisualizationPart from '../components/VisualizationPart.vue'
|
import VisualizationPart from '../components/game/VisualizationPart.vue'
|
||||||
import ControlPalette from '../components/ControlPalette.vue'
|
import ControlPalette from '../components/game/ControlPalette.vue'
|
||||||
import CompleteModal from '../components/CompleteModal.vue'
|
import CompleteModal from '../components/game/CompleteModal.vue'
|
||||||
import { LoadQuestError, LoadSessionError } from '../utils/errors'
|
import StartButton from '../components/game/StartButton.vue'
|
||||||
|
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({
|
||||||
@@ -23,43 +34,30 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
sessionStore.reset()
|
||||||
await init(props.questId)
|
sessionStore.setup()
|
||||||
} catch (err) {
|
await sessionStore.setQuest(props.questId)
|
||||||
if (err instanceof LoadQuestError) {
|
await sessionStore.getActiveSession()
|
||||||
// toast.error(`Failed to load quest: ${err.questId}. Please check the URL.`)
|
|
||||||
toast.error(`無法讀取關卡: ${err.questId},請確認網頁連結。`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (err instanceof LoadSessionError) {
|
|
||||||
// toast.error(
|
|
||||||
// `Failed to create session for quest: ${err.questId}. Please re-login and try again.`
|
|
||||||
// )
|
|
||||||
toast.error(`無法建立工作階段: ${err.questId},請重新登入再試一次。`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error(err)
|
|
||||||
toast.error(err.message)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- game header -->
|
<!-- game header -->
|
||||||
<div class="h-[6vh] w-full">
|
<div class="w-full">
|
||||||
<GameHeaderPart :title="sessionStore.quest?.title" />
|
<HeaderPart
|
||||||
|
: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-[94vh] w-full flex p-3 space-x-3">
|
<div id="main" class="h-full pt-10 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">
|
||||||
@@ -71,9 +69,10 @@ onMounted(async () => {
|
|||||||
</section> -->
|
</section> -->
|
||||||
</div>
|
</div>
|
||||||
<!-- Terminal and Hint -->
|
<!-- Terminal and Hint -->
|
||||||
<div class="bg-bg h-full w-1/2 rounded-lg">
|
<div class="bg-bg h-full rounded-lg w-1/2">
|
||||||
|
<StartButton :questId="$props.questId" />
|
||||||
<section id="hint" class="h-3/5">
|
<section id="hint" class="h-3/5">
|
||||||
<HintPart />
|
<DescriptionPart />
|
||||||
</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">
|
||||||
|
|||||||
+28
-102
@@ -1,97 +1,19 @@
|
|||||||
<script setup>
|
<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 { bugReportUrl, facebookUrl, githubUrl } from '../config'
|
import LandingHeaderComponents from '../components/header/LandingHeaderComponents.vue'
|
||||||
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
|
|
||||||
const scrollToSection = (sectionId) => {
|
import { facebookUrl, githubUrl } from '../config'
|
||||||
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 - container.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 -->
|
||||||
<div
|
<HeaderPart :headerComponent="LandingHeaderComponents" class="z-50" />
|
||||||
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-[94vh] overflow-y-auto">
|
<main class="w-full h-full 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"
|
||||||
@@ -104,7 +26,6 @@ const scrollToSection = (sectionId) => {
|
|||||||
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
|
||||||
@@ -228,40 +149,43 @@ const scrollToSection = (sectionId) => {
|
|||||||
鈦坦敏捷開發特別獎 第二名
|
鈦坦敏捷開發特別獎 第二名
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="container mx-auto">
|
||||||
<!-- <h1
|
<div
|
||||||
class="text-text-primary p-5 font-extrabold font-mono col-span-3 gap-3"
|
class="justify-items-center grid grid-cols-1 sm:grid-cols-3 gap-5"
|
||||||
style="font-size: 3.5vh"
|
>
|
||||||
|
<h1
|
||||||
|
class="text-text-primary text-3xl font-extrabold font-mono sm:col-span-3 gap-6"
|
||||||
>
|
>
|
||||||
玩家感想
|
玩家感想
|
||||||
|
<!--可以做成翻頁卡牌一次一個-->
|
||||||
</h1>
|
</h1>
|
||||||
<h1 class="text-text p-3" style="font-size: 2.5vh">
|
<h1 class="text-xl text-center">"很好玩很像益智遊戲"</h1>
|
||||||
"很好玩很像益智遊戲"
|
<h1 class="text-xl text-center">
|
||||||
</h1>
|
|
||||||
<h1 class="text-text p-3" style="font-size: 2.5vh">
|
|
||||||
"我從來不知道自由軟體的存在,謝謝你們。"
|
"我從來不知道自由軟體的存在,謝謝你們。"
|
||||||
</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
|
||||||
@@ -271,7 +195,9 @@ const scrollToSection = (sectionId) => {
|
|||||||
<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 class="flex flex-row items-center justify-around mt-5 gap-5">
|
<div
|
||||||
|
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']"
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
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 GameHeaderPart from '../components/GameHeaderPart.vue'
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
|
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
|
||||||
const leaderboard = ref([])
|
const leaderboard = ref<
|
||||||
|
{
|
||||||
|
username: string
|
||||||
|
score: number
|
||||||
|
completedQuests: string[]
|
||||||
|
}[]
|
||||||
|
>([])
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
async function getLeaderboard() {
|
async function getLeaderboard() {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/leaderboard')
|
const res = await api.get('/leaderboard')
|
||||||
leaderboard.value = res.data
|
leaderboard.value = res.data
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
toast.error('讀取排行榜失敗')
|
toast.error('讀取排行榜失敗')
|
||||||
}
|
}
|
||||||
@@ -29,8 +36,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="h-[6vh] w-full">
|
<div class="w-full">
|
||||||
<GameHeaderPart title="排行榜" />
|
<HeaderPart title="排行榜" :headerComponent="GameHeaderComponents" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-fit h-[94vh] m-3">
|
<div class="w-fit h-[94vh] m-3">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import AuthForm from '../components/AuthForm.vue'
|
import AuthForm from '../components/AuthForm.vue'
|
||||||
import Background from '../components/DynamicBackground.vue'
|
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||||
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TooManyRequestsError,
|
TooManyRequestsError,
|
||||||
@@ -9,28 +10,35 @@ import {
|
|||||||
} from '../utils/errors'
|
} from '../utils/errors'
|
||||||
import { login } from '../utils/auth'
|
import { login } from '../utils/auth'
|
||||||
|
|
||||||
const handleLogin = async ({ username, password, success, error }) => {
|
const handleLogin = async ({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
success: () => void
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
error: (msg: string) => void
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
const isSuccess = await login(username, password)
|
const isSuccess = await login(username, password)
|
||||||
if (isSuccess) success()
|
if (isSuccess) success()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
// error('Too many requests. Try again in 2 minutes.')
|
|
||||||
error('太多請求,兩分鐘後再試一次。')
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof UnauthorizedError) {
|
if (err instanceof UnauthorizedError) {
|
||||||
// error('Wrong username or password.')
|
|
||||||
error('錯誤的帳號名稱或密碼。')
|
error('錯誤的帳號名稱或密碼。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof ValidationError) {
|
if (err instanceof ValidationError) {
|
||||||
// error('Invalid username or password.')
|
|
||||||
error('無效的帳號名稱或密碼。')
|
error('無效的帳號名稱或密碼。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
// error('Something went wrong.')
|
|
||||||
error('出了點問題。')
|
error('出了點問題。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,10 +46,11 @@ const handleLogin = async ({ username, password, success, error }) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-screen h-screen">
|
<div class="w-screen h-screen">
|
||||||
<Background class="w-full h-full" />
|
<DynamicBackground 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>
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import QuestMap from '../components/QuestMap.vue'
|
import QuestMap from '../components/QuestMap.vue'
|
||||||
import GameHeaderPart from '../components/GameHeaderPart.vue'
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
|
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-[6vh] w-full">
|
<div class="w-fit">
|
||||||
<GameHeaderPart title="關卡地圖" />
|
<HeaderPart
|
||||||
<!-- <p
|
title="關卡地圖"
|
||||||
class="text-text-primary absolute z-1 font-bold inline-block tracking-widest"
|
:headerComponent="GameHeaderComponents"
|
||||||
style="left: 1.2%; top: 1.5%; height: 100%; font-size: 2vh"
|
class="z-50"
|
||||||
>
|
/>
|
||||||
Quest Map
|
|
||||||
Unix編年史
|
|
||||||
</p> -->
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-center items-center w-full h-[94vh]">
|
<div class="flex flex-col justify-center items-center w-full h-full pt-5">
|
||||||
<QuestMap />
|
<QuestMap />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import { isValidUsername } from '@linux-odyssey/utils'
|
import { isValidUsername } from '@linux-odyssey/utils'
|
||||||
import { passwordPolicy } from '@linux-odyssey/constants'
|
import { passwordPolicy } from '@linux-odyssey/constants'
|
||||||
import AuthForm from '../components/AuthForm.vue'
|
import AuthForm from '../components/AuthForm.vue'
|
||||||
import Background from '../components/DynamicBackground.vue'
|
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||||
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
import { checkUsername, register } from '../utils/auth'
|
import { checkUsername, register } from '../utils/auth'
|
||||||
import {
|
import {
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
@@ -12,17 +12,28 @@ import {
|
|||||||
ValidationError,
|
ValidationError,
|
||||||
} from '../utils/errors'
|
} from '../utils/errors'
|
||||||
|
|
||||||
function handleRegister({ username, email, password, success, error }) {
|
function handleRegister({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
success: () => void
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
error: (msg: string) => void
|
||||||
|
}) {
|
||||||
register(username, email, password)
|
register(username, email, password)
|
||||||
.then(success)
|
.then(success)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
// error('Too many requests. Try again in 2 minutes.')
|
|
||||||
error('太多請求,兩分鐘後再試一次。')
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof UnauthorizedError) {
|
if (err instanceof UnauthorizedError) {
|
||||||
// error('Invalid username or password.')
|
|
||||||
error('無效的帳號名稱或密碼。')
|
error('無效的帳號名稱或密碼。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -31,26 +42,31 @@ function handleRegister({ username, email, password, success, error }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
// error('Something went wrong.')
|
|
||||||
error('出了點問題。')
|
error('出了點問題。')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function check({ username, email, password, error }) {
|
async function check({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
error: (msg: string) => void
|
||||||
|
}) {
|
||||||
if (username && !isValidUsername(username)) {
|
if (username && !isValidUsername(username)) {
|
||||||
// error('Invalid username.')
|
|
||||||
error('無效的帳號名稱')
|
error('無效的帳號名稱')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (email && !validator.isEmail(email)) {
|
if (email && !validator.isEmail(email)) {
|
||||||
// error('Invalid email.')
|
|
||||||
error('無效的電子郵件')
|
error('無效的電子郵件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (password && !validator.isStrongPassword(password, passwordPolicy)) {
|
if (password && !validator.isStrongPassword(password, passwordPolicy)) {
|
||||||
// error(
|
|
||||||
// 'Your password must be 8+ characters with at least one number, one upper and one lower case letter.'
|
|
||||||
// )
|
|
||||||
error('密碼必須超過8個字元,至少包含一個數字、一個大寫及一個小寫。')
|
error('密碼必須超過8個字元,至少包含一個數字、一個大寫及一個小寫。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -62,12 +78,10 @@ async function check({ username, email, password, error }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
// error('Too many requests. Try again in 2 minutes.')
|
|
||||||
error('太多請求,兩分鐘後再試一次。')
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
// error('Something went wrong.')
|
|
||||||
error('出了點問題。')
|
error('出了點問題。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,11 +89,12 @@ async function check({ username, email, password, error }) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-screen h-screen">
|
<div class="w-screen h-screen">
|
||||||
<Background class="w-full h-full" />
|
<DynamicBackground 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"
|
||||||
>
|
>
|
||||||
<div class="w-fit">
|
<HeaderPart />
|
||||||
|
<div class="w-fit mt-8">
|
||||||
<AuthForm
|
<AuthForm
|
||||||
@onSubmit="handleRegister"
|
@onSubmit="handleRegister"
|
||||||
@onChange="check"
|
@onChange="check"
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export default {
|
|||||||
DEFAULT: '#FF0000',
|
DEFAULT: '#FF0000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
catelogbg: "url('/catelogbg.svg')",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
+3
-3
@@ -13,7 +13,6 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"allowJs": true,
|
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -21,10 +20,11 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.js"],
|
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../packages/utils" },
|
{ "path": "../packages/utils" },
|
||||||
{ "path": "../packages/constants" },
|
{ "path": "../packages/constants" },
|
||||||
{ "path": "../packages/file-graph" }
|
{ "path": "../packages/file-graph" },
|
||||||
|
{ "path": "../packages/models" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
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
|
||||||
@@ -52,5 +52,9 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"latest": "^0.2.0",
|
||||||
|
"typescript-eslint": "^7.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ADMIN_USERNAME=alex
|
||||||
|
ADMIN_PASSWORD=Alex1234
|
||||||
@@ -4,8 +4,7 @@ WORKDIR /app
|
|||||||
COPY ./package.json yarn.lock tsconfig.json ./
|
COPY ./package.json yarn.lock tsconfig.json ./
|
||||||
COPY packages/analytics/package.json ./packages/analytics/package.json
|
COPY packages/analytics/package.json ./packages/analytics/package.json
|
||||||
COPY packages/models/package.json ./packages/models/package.json
|
COPY packages/models/package.json ./packages/models/package.json
|
||||||
RUN yarn set version berry
|
RUN yarn install --frozen-lockfile
|
||||||
RUN yarn install
|
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=3001
|
ENV PORT=3001
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
@@ -18,10 +17,3 @@ ENV NODE_ENV=production
|
|||||||
WORKDIR /app/packages/analytics
|
WORKDIR /app/packages/analytics
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
CMD [ "yarn", "start" ]
|
CMD [ "yarn", "start" ]
|
||||||
|
|
||||||
# Development stage
|
|
||||||
FROM build as dev
|
|
||||||
RUN yarn install --frozen-lockfile
|
|
||||||
WORKDIR /app/packages/analytics
|
|
||||||
ENV NODE_ENV=development
|
|
||||||
CMD [ "yarn", "dev" ]
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"ext": "js,handlebars"
|
"watch": ["src", "views"],
|
||||||
|
"ext": "ts,handlebars,js",
|
||||||
|
"ignore": ["src/**/*.spec.ts"],
|
||||||
|
"exec": "node --loader ts-node/esm src/index.ts"
|
||||||
}
|
}
|
||||||
@@ -6,19 +6,27 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "GPL",
|
"license": "GPL",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node dist/index.js",
|
||||||
"build": "tsc",
|
"build": "tsc -b -f",
|
||||||
"dev": "nodemon src/index.js"
|
"watch": "tsc -b --watch",
|
||||||
|
"dev": "nodemon"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@linux-odyssey/models": "*",
|
"@linux-odyssey/models": "*",
|
||||||
"express": "^4.18.2",
|
"dotenv": "^16.4.5",
|
||||||
|
"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.0.3"
|
"mongoose": "^8.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1",
|
"@swc/core": "^1.4.2",
|
||||||
|
"@swc/helpers": "^0.5.6",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.11.20",
|
||||||
|
"nodemon": "^3.1.0",
|
||||||
|
"regenerator-runtime": "^0.14.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ th {
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 2px solid black;
|
border: 2px solid #dcdcdd;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
@@ -17,3 +17,117 @@ td {
|
|||||||
width: 1px;
|
width: 1px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background-color: #000000;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title h1 {
|
||||||
|
color: #00ff00;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 50px;
|
||||||
|
margin-top: 50px;
|
||||||
|
font-family:
|
||||||
|
ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
Menlo,
|
||||||
|
Monaco,
|
||||||
|
Consolas,
|
||||||
|
Liberation Mono,
|
||||||
|
Courier New,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title img {
|
||||||
|
width: 135px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #dcdcdd;
|
||||||
|
|
||||||
|
background-color: #24262e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table a {
|
||||||
|
color: #00dd00;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
color: #dcdcdd;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #dcdcdd;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: #24262e;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort a {
|
||||||
|
background-color: #24262e;
|
||||||
|
height: 50px;
|
||||||
|
padding: 0 5 0 5;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort .btn h3 {
|
||||||
|
margin: 15 3 0 3;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dcdcdd;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="638" height="732" viewBox="0 0 638 732" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<path d="M0 0H638V732H0V0Z" fill="url(#pattern0)"/>
|
||||||
|
<defs>
|
||||||
|
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||||
|
<use xlink:href="#image0_379_14" transform="matrix(0.00126582 0 0 0.00110327 0 -0.000333748)"/>
|
||||||
|
</pattern>
|
||||||
|
<image id="image0_379_14" width="790" height="907" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxYAAAOLCAYAAADNRhU6AAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQEGAAMAAAABAAIAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAAEIAAAAAQAAAQgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAxagAwAEAAAAAQAAA4sAAAAA8/vYWgAAAAlwSFlzAAAomgAAKJoBFzohsgAAA4JpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDYuMC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6SXB0YzR4bXBFeHQ9Imh0dHA6Ly9pcHRjLm9yZy9zdGQvSXB0YzR4bXBFeHQvMjAwOC0wMi0yOS8iPgogICAgICAgICA8ZGM6dGl0bGU+CiAgICAgICAgICAgIDxyZGY6QWx0PgogICAgICAgICAgICAgICA8cmRmOmxpIHhtbDpsYW5nPSJ4LWRlZmF1bHQiPmF0dGF0Y2htZW50LlpjcjBOQTwvcmRmOmxpPgogICAgICAgICAgICA8L3JkZjpBbHQ+CiAgICAgICAgIDwvZGM6dGl0bGU+CiAgICAgICAgIDx0aWZmOllSZXNvbHV0aW9uPjI2NDwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6WFJlc29sdXRpb24+MjY0PC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpQaG90b21ldHJpY0ludGVycHJldGF0aW9uPjI8L3RpZmY6UGhvdG9tZXRyaWNJbnRlcnByZXRhdGlvbj4KICAgICAgICAgPHRpZmY6UmVzb2x1dGlvblVuaXQ+MjwvdGlmZjpSZXNvbHV0aW9uVW5pdD4KICAgICAgICAgPElwdGM0eG1wRXh0OkFydHdvcmtUaXRsZT5hdHRhdGNobWVudC5aY3IwTkE8L0lwdGM0eG1wRXh0OkFydHdvcmtUaXRsZT4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+Cm9mQRgAAEAASURBVHgB7L0HuCxHdS66Ju+c94k6Eorn6ASFIwlJSEhCgBAgiQwmw8MG7OdwMWC/z8bm2s/c+2ziNTa+tq8NlgPXcLHBmGSQSQIEAhQJCgiQBNIJO6fJ/f6/umumZ/bEvSfPqr17uru6wqq/OqxVa60qEQ2KgCKgCCgCikAVBCanj/xqlSR6WRFQBBQBRUARUAQUAUVAEVAEFIHyCAyNnuPs2H3U2bnnon8vn0qvKAKKgCKgCPQ7AuF+B0DbrwgoAoqAIlAegfEdh88MZh0kyMr6+sazy6fUK4qAIqAIKAL9jkCw3wHQ9isCioAioAiURmB29tBZg9HIgwF8KVZWls02OnEOpQwNioAioAgoAorAJgRUsNgEiUYoAoqAIqAIEIFgJPJAfCNuwNhYXRFxHFlZXpbZXeepcKG3iCKgCCgCisAmBEKbYjRCEVAEFAFFoO8RmN11xInH45AlHFlcmBcJBLDhPxiQtbVVmd155k/WV4/d1fdAKQCKgCKgCCgCOQTwmdCgCCgCioAioAjkERif2u9EoxHZ2FiXVDIhqXRKsplMLkEAQkYgGJJs6ph+Q3Ko6IEioAgoAoqAfhT0HlAEFAFFQBHIIbBj9wVLgaAztgqfinh83QgUTiaN67B+MloLa0EL4SIUESf1uH5HcujpgSKgCCgC/Y2A/UL0NwraekVAEVAEFAEZnz50bTabHqN2Yh3aikw6DVOorCdQePIDz7lB0HBScRkaO139LfTeUQQUAUVAETAIqGChN4IioAgoAoqAQSAUklt4sLS0JA6EC/pTmC2ETwWnhuJmA3wvwrGYrC8vyuDY6cs2WveKgCKgCCgC/YuAOm/3b99ryxUBRUARyCGwe99RJwQBYmV1WRLxDUwJBQ0F/SqMhsIzgzKaCpslII6nxIDPRWxq+tSl9bUTt9mrulcEFAFFQBHoPwTUNrb/+lxbrAgoAopAAQK7TrkQbhRpWV1bgV/FBrQVKQgW0E4Yh+3ylk6BcFgcOHZTkxGJDUpq4+f6TSlAVk8UAUVAEegvBPQj0F/9ra1VBBQBRaAAgZHxM50ghAjO/MRZoCTrc9Rmyiz9KUqHQCQEwQLpKXsEQhKNDUly41H9rpSGS2MVAUVAEeh5BHwGsz3fVm2gIqAIKAKKgA+B6R3nvmpgICZDQwNiFsCjhEBNBWd/otlTBaGCa1rQD0Mw7WwoGpUQtBdJCCbj02eXV3H46tZDRUARUAQUgd5DQEeWeq9PtUWKgCKgCNSEwL7TjmIRvA05cfwY0vvkAfpXUKioOvTk/4QEIJNgbYtMVkZHR2Vl6Sf+izXRo4kUAUVAEVAEuhuBqp+N7m6eUq8IKAKKgCJQCoFJLIKXTqVkfu4kNBRMQcHC2yppKooLy2XDLFGcPUqy8NVYlrGJM44XJ9VzRUARUAQUgd5GQAWL3u5fbZ0ioAgoApsQGBs/xxkeHpbl5SWsVZHwppTdyiSBkCoC+S2b5RS1EE/gd4GyZycmDlyzqXKNUAQUAUVAEehZBFRV3bNdqw1TBBQBRWAzAtMzB53JySl59GcPSyIBZ20IBpzZKRIbkFQyWZih4tATVRVFgZoO+mcwZLEGRngQZevK3C4g+qsIKAKKQO8jUPGz0fvN1xYqAoqAItA/COzee8HvRuFoffz4cYlvxLGqNoSKLASEYFhSKczuZBfBM/taxp0oXPg2+mYwG7cQ1rnIJGVsUp25++cO05YqAopAvyOggkW/3wHafkVAEegbBKBD+KMktBLLWFkbUkWD2u0TLPxCBnwtoK6Q5YWTMjGzv1GVNYhmLUYRUAQUAUWgGQjUMiTVjHq1TEVAEVAEFIEWIkATKGooFhYWMXtTwPhWuLM+leP5EU//ibKB1yA8GGGiVCJ+XuhwgX0wIuPjk7I0d79+c0pBpXGKgCKgCPQIAlvx1uuRpmszFAFFQBHoDwQoVLClq6ur+M1CqIDZE9eryGkYeLUo1CQCsFgmLLV50Wba2qBQUzIxeepkfOPkZ4tq0lNFQBFQBBSBHkGgpk9Hj7RVm6EIKAKKQN8hMDN7CCtrh+BXcUxCmA42w0XtGIw2AscBah22EippM1gerps6+Jmh5iIokcigTI1Pjxw7dvfaVmrUPIqAIqAIKAKdjYD6WHR2/yh1ioAioAhsGYHx8XP+OhaLyeLiAhj7jGQwHaxh9g3DT8GgmnBQqepSWoriOOa39TiYdSouiyuLVJtoUAQUAUVAEehBBFRj0YOdqk1SBBQBRYAI7Np9nnPy5ElJp5ISCoddwcLH6Bum33wFtiNglMMaZea0Ia7GIoDZpxzMPhUbGpXE+sP6/SkHncYrAoqAItClCKjGoks7TslWBBQBRaASAjSB2ohvQKhIIFlWso51tM5rECrlb+w1ChmY2pa0wBwrkdyQsSmdhraxGGtpioAioAi0HwEVLNrfB0qBIqAIKAINRWB2xyFw8o6srqy45cLHwjGCRVE1OZOooviGnfqVEiApzE+ONw3t0rxMzh74UcOq0oIUAUVAEVAE2o6AzgrV9i5QAhQBRUARaBwCk1MHnCxmYppfmIdbRco3+5Otw9NY+Hl+e6nRe1NHUUUUZoxAIxKPJyfHJ/Z9I7FxUgWMRmOv5SkCioAi0AYEwm2oU6tUBBQBRUARaAICI6NnfSMUCskKNBWBAFa+5krYQodtf2UULNoVbN0ULkBDOiXLK0ufw1EBhe2iTutVBBQBRUAR2B4Cagq1Pfw0tyKgCCgCHYPA6OjIZQvQVCTinHiJTDzMjnLmTjy3jH2LSK5YHS6CNiedlMjA3oopW0StVqMIKAKKgCKwTQR0lGibAGp2RUARUAQ6AYFdu8935ufnJJmAg7QZMqJQYR2220FhjbKCSRaSgYFhia8/ot+kdnSV1qkIKAKKQIMQUI1Fg4DUYhQBRUARaBcCU9PnOuvr6xAq4p5QQW7dbu2iijJCpY10gUYmwRob8fi6jIyebsQMXtGgCCgCioAi0H0IqPN29/WZUqwIKAKKQA4BTivLGZ8WYQIlxqfCChQe055L2akHkCwCGOMCual0RkZHd48nE/P0u9CgCCgCioAi0GUIcKxIgyKgCCgCikAXIjA9c/DxSCS8010EjyZQZvgfLfGEC2/2pc5smhWASDMFC+yzjON4186YyPeSnUm3UqUIKAKKgCJQDgE1hSqHjMYrAoqAItDBCIyMXDjrOLJzYWFR0uk0ePNufJ1TqMBGeYIB623QoTscmYeUpEERUAQUAUWg2xDoxi9Rt2Gs9CoCioAi0HAEhoazx9fW1iRBZ23y510XPKGCdBv6HUyRCwkDx+l0QkLRXVbc6LqWKcGKgCKgCPQrAipY9GvPa7sVAUWgaxGY3XG+M3dyzhUqTCvIg3czH06Bwt0CQeyDWcmkEjIw/IRublTX3l9KuCKgCCLine truncated
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 630 KiB |
@@ -1,18 +0,0 @@
|
|||||||
function getOrFailed(key) {
|
|
||||||
if (!process.env[key]) {
|
|
||||||
throw new Error(`Missing required environment variable: ${key}`)
|
|
||||||
}
|
|
||||||
return process.env[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
host: process.env.HOST || 'localhost',
|
|
||||||
port: process.env.PORT || 3001,
|
|
||||||
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
|
|
||||||
username: getOrFailed('ADMIN_USERNAME'),
|
|
||||||
password: getOrFailed('ADMIN_PASSWORD'),
|
|
||||||
}
|
|
||||||
|
|
||||||
config.baseUrl = process.env.BASE_URL || `http://${config.host}:${config.port}`
|
|
||||||
|
|
||||||
export default config
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import dotenv from 'dotenv'
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
dotenv.config({ path: '.env.dev' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrFailed(key: string): string {
|
||||||
|
if (!process.env[key]) {
|
||||||
|
throw new Error(`Missing required environment variable: ${key}`)
|
||||||
|
}
|
||||||
|
return process.env[key] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
db: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
host: process.env.HOST || 'localhost',
|
||||||
|
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3001,
|
||||||
|
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
|
||||||
|
username: getOrFailed('ADMIN_USERNAME'),
|
||||||
|
password: getOrFailed('ADMIN_PASSWORD'),
|
||||||
|
baseUrl:
|
||||||
|
process.env.BASE_URL ||
|
||||||
|
`http://${process.env.HOST || 'localhost'}:${process.env.PORT ? parseInt(process.env.PORT, 10) : 3001}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
File renamed without changes.
+6
-4
@@ -1,10 +1,12 @@
|
|||||||
/* eslint-disable object-shorthand */
|
import type { Request, Response } from 'express'
|
||||||
import Pagination from '../models/pagination.js'
|
import Pagination from '../models/pagination.js'
|
||||||
import { errorCommands } from '../models/commands.js'
|
import { errorCommands } from '../models/commands.js'
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
export async function commandListController(
|
||||||
export async function commandListController(req, res) {
|
req: Request,
|
||||||
const { nextKey, order } = req.query
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const { nextKey, order } = req.query as { nextKey?: string; order: string }
|
||||||
const itemsPerPage = 100
|
const itemsPerPage = 100
|
||||||
|
|
||||||
try {
|
try {
|
||||||
+3
-4
@@ -1,8 +1,8 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
import { userCount } from '../models/users.js'
|
import { userCount } from '../models/users.js'
|
||||||
import { questList } from '../models/quests.js'
|
import { questList } from '../models/quests.js'
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
export async function homeController(req: Request, res: Response) {
|
||||||
export async function homeController(req, res) {
|
|
||||||
const quests = await questList()
|
const quests = await questList()
|
||||||
const [totalQuests, totalCompleted] = quests.reduce(
|
const [totalQuests, totalCompleted] = quests.reduce(
|
||||||
([total, completed], quest) => [
|
([total, completed], quest) => [
|
||||||
@@ -17,8 +17,7 @@ export async function homeController(req, res) {
|
|||||||
userCount: await userCount(),
|
userCount: await userCount(),
|
||||||
totalQuests,
|
totalQuests,
|
||||||
totalCompleted,
|
totalCompleted,
|
||||||
|
completedRate: Math.round((totalCompleted / totalQuests) * 10000) / 100,
|
||||||
}
|
}
|
||||||
payload.completedRate =
|
|
||||||
Math.round((payload.totalCompleted / payload.totalQuests) * 10000) / 100
|
|
||||||
res.render('home', payload)
|
res.render('home', payload)
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
import { questList } from '../models/quests.js'
|
import { questList } from '../models/quests.js'
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
export async function questListController(req: Request, res: Response) {
|
||||||
export async function questListController(req, res) {
|
|
||||||
const quests = await questList()
|
const quests = await questList()
|
||||||
quests.forEach((quest) => {
|
quests.forEach((quest) => {
|
||||||
quest.failed = quest.started - quest.completed
|
quest.failed = quest.started - quest.completed
|
||||||
+4
-3
@@ -1,8 +1,9 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
import Pagination from '../models/pagination.js'
|
import Pagination from '../models/pagination.js'
|
||||||
import { sessionList, sessionDetail } from '../models/sessions.js'
|
import { sessionList, sessionDetail } from '../models/sessions.js'
|
||||||
|
|
||||||
export async function sessionListController(req, res) {
|
export async function sessionListController(req: Request, res: Response) {
|
||||||
const { nextKey, order } = req.query
|
const { nextKey, order } = req.query as { nextKey: string; order: string }
|
||||||
const itemsPerPage = 50
|
const itemsPerPage = 50
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -21,7 +22,7 @@ export async function sessionListController(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sessionDetailController(req, res) {
|
export async function sessionDetailController(req: Request, res: Response) {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
const session = await sessionDetail(id)
|
const session = await sessionDetail(id)
|
||||||
res.render('sessionDetail', { session })
|
res.render('sessionDetail', { session })
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import Pagination from '../models/pagination.js'
|
|
||||||
import { userList } from '../models/users.js'
|
|
||||||
|
|
||||||
export async function userListController(req, res) {
|
|
||||||
const { nextKey, order } = req.query
|
|
||||||
const itemsPerPage = 50
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pagination = new Pagination(order, itemsPerPage, nextKey)
|
|
||||||
const users = await userList(pagination)
|
|
||||||
const newNextKey = users[users.length - 1]?._id
|
|
||||||
|
|
||||||
res.render('users', {
|
|
||||||
users,
|
|
||||||
nextKey: newNextKey,
|
|
||||||
order: pagination.getOrder(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
res.status(500).send('Error fetching user data')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
import Pagination from '../models/pagination.js'
|
||||||
|
import { userList, userDetail, idToUser } from '../models/users.js'
|
||||||
|
|
||||||
|
export async function userListController(req: Request, res: Response) {
|
||||||
|
const { nextKey, order } = req.query as { nextKey: string; order: string }
|
||||||
|
const itemsPerPage = 50
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pagination = new Pagination(order, itemsPerPage, nextKey)
|
||||||
|
const users = await userList(pagination)
|
||||||
|
const newNextKey = users[users.length - 1]?._id
|
||||||
|
|
||||||
|
res.render('users', {
|
||||||
|
users,
|
||||||
|
nextKey: newNextKey,
|
||||||
|
order: pagination.getOrder(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).send('Error fetching user data')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userDetailController(req: Request, res: Response) {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id)
|
||||||
|
const sessions = await userDetail(id)
|
||||||
|
try {
|
||||||
|
const user = await idToUser(id)
|
||||||
|
res.render('usersDetail', { sessions, user })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).send('Error fetching user data')
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,61 @@
|
|||||||
|
import { Types } from 'mongoose'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
export interface SessionDocument {
|
||||||
|
_id: Types.ObjectId
|
||||||
|
user: User
|
||||||
|
quest: string
|
||||||
|
status: string
|
||||||
|
createdAt: Date
|
||||||
|
finishedAt: Date
|
||||||
|
}
|
||||||
|
export interface CommandObject {
|
||||||
|
command: string
|
||||||
|
pwd: string
|
||||||
|
stage?: string | null
|
||||||
|
output: string | undefined
|
||||||
|
error: string | undefined
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
export interface SessionObject {
|
||||||
|
_id: Types.ObjectId
|
||||||
|
user: string
|
||||||
|
quest: string
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
finishedAt: string
|
||||||
|
lastActivityAt: string
|
||||||
|
usedTime: string
|
||||||
|
commandCount: number
|
||||||
|
commands: CommandObject[]
|
||||||
|
}
|
||||||
|
export interface SessionDetail {
|
||||||
|
_id: Types.ObjectId
|
||||||
|
user: string
|
||||||
|
quest: string
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
finishedAt: string
|
||||||
|
usedTime: string
|
||||||
|
commands: CommandObject[]
|
||||||
|
}
|
||||||
|
export interface IUser extends Document {
|
||||||
|
hashedPassword?: string
|
||||||
|
socialLogins?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
provider: string
|
||||||
|
displayName?: string | null | undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
export interface UserSessionDetail {
|
||||||
|
_id: Types.ObjectId
|
||||||
|
quest: string
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
usedTime: string
|
||||||
|
}
|
||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
import { Command } from '@linux-odyssey/models'
|
import { Command } from '@linux-odyssey/models'
|
||||||
|
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) {
|
export async function errorCommands(pagination: Pagination) {
|
||||||
const commands = await Command.aggregate([
|
const commands = await Command.aggregate([
|
||||||
pagination.match('_id'),
|
pagination.match('_id'),
|
||||||
{
|
{
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
/* eslint-disable lines-between-class-members */
|
|
||||||
/* eslint-disable class-methods-use-this */
|
|
||||||
// eslint-disable-next-line max-classes-per-file
|
|
||||||
import mongoose from 'mongoose'
|
|
||||||
|
|
||||||
export const DESC = 'desc'
|
|
||||||
export const ASC = 'asc'
|
|
||||||
|
|
||||||
class Asc {
|
|
||||||
getOperator() {
|
|
||||||
return '$gt'
|
|
||||||
}
|
|
||||||
|
|
||||||
getOrder() {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
getName() {
|
|
||||||
return ASC
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Desc {
|
|
||||||
getOperator() {
|
|
||||||
return '$lt'
|
|
||||||
}
|
|
||||||
|
|
||||||
getOrder() {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
getName() {
|
|
||||||
return DESC
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class Pagination {
|
|
||||||
nextKey = null
|
|
||||||
itemsPerPage = 50
|
|
||||||
order = null
|
|
||||||
|
|
||||||
constructor(order, itemsPerPage = 50, nextKey = null) {
|
|
||||||
this.itemsPerPage = itemsPerPage
|
|
||||||
if (!order || order === DESC) {
|
|
||||||
this.order = new Desc()
|
|
||||||
} else if (order === ASC) {
|
|
||||||
this.order = new Asc()
|
|
||||||
} else {
|
|
||||||
throw new Error('order must be asc or desc')
|
|
||||||
}
|
|
||||||
if (nextKey) {
|
|
||||||
this.nextKey = new mongoose.Types.ObjectId(nextKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match(field) {
|
|
||||||
const stage = this.nextKey
|
|
||||||
? { [field]: { [this.order.getOperator()]: this.nextKey } }
|
|
||||||
: {}
|
|
||||||
return { $match: stage }
|
|
||||||
}
|
|
||||||
|
|
||||||
sort(field) {
|
|
||||||
return { $sort: { [field]: this.order.getOrder() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
limit() {
|
|
||||||
return { $limit: this.itemsPerPage }
|
|
||||||
}
|
|
||||||
|
|
||||||
getOrder() {
|
|
||||||
return this.order.getName()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/* eslint-disable class-methods-use-this */
|
||||||
|
// eslint-disable-next-line max-classes-per-file
|
||||||
|
import mongoose, { Types } from 'mongoose'
|
||||||
|
|
||||||
|
export const DESC: string = 'desc'
|
||||||
|
export const ASC: string = 'asc'
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
getOperator(): string
|
||||||
|
getOrder(): number
|
||||||
|
getName(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
class Asc implements Order {
|
||||||
|
getOperator(): string {
|
||||||
|
return '$gt'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrder(): number {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return ASC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Desc implements Order {
|
||||||
|
getOperator(): string {
|
||||||
|
return '$lt'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrder(): 1 | -1 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return DESC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Pagination {
|
||||||
|
nextKey: Types.ObjectId | null = null
|
||||||
|
itemsPerPage: number = 50
|
||||||
|
order: Order
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
order: string,
|
||||||
|
itemsPerPage: number = 50,
|
||||||
|
nextKey: string | null = null
|
||||||
|
) {
|
||||||
|
this.itemsPerPage = itemsPerPage
|
||||||
|
if (!order || order === DESC) {
|
||||||
|
this.order = new Desc()
|
||||||
|
} else if (order === ASC) {
|
||||||
|
this.order = new Asc()
|
||||||
|
} else {
|
||||||
|
throw new Error('order must be asc or desc')
|
||||||
|
}
|
||||||
|
if (nextKey) {
|
||||||
|
this.nextKey = new mongoose.Types.ObjectId(nextKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match(field: string) {
|
||||||
|
const stage: { [key: string]: { [key: string]: Types.ObjectId } } = this
|
||||||
|
.nextKey
|
||||||
|
? {
|
||||||
|
[field]: {
|
||||||
|
[this.order ? this.order.getOperator() : '$gt']: this.nextKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
return { $match: stage }
|
||||||
|
}
|
||||||
|
|
||||||
|
sort(field: string): { $sort: Record<string, 1 | -1> } {
|
||||||
|
return { $sort: { [field]: this.order.getOrder() as 1 | -1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
limit() {
|
||||||
|
return { $limit: this.itemsPerPage }
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrder() {
|
||||||
|
return this.order.getName()
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -1,83 +0,0 @@
|
|||||||
import { Command, Session } from '@linux-odyssey/models'
|
|
||||||
|
|
||||||
function formatTime(time) {
|
|
||||||
const seconds = Math.floor(time / 1000)
|
|
||||||
const minutes = Math.floor(seconds / 60)
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sessionList(pagination) {
|
|
||||||
const sessions = await Session.aggregate([
|
|
||||||
pagination.match('_id'),
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: 'users',
|
|
||||||
localField: 'user',
|
|
||||||
foreignField: '_id',
|
|
||||||
as: 'user',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ $unwind: '$user' },
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: 'commands',
|
|
||||||
localField: '_id',
|
|
||||||
foreignField: 'session',
|
|
||||||
as: 'commands',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pagination.sort('_id'),
|
|
||||||
pagination.limit(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return sessions.map(
|
|
||||||
({
|
|
||||||
_id,
|
|
||||||
user,
|
|
||||||
quest,
|
|
||||||
status,
|
|
||||||
createdAt,
|
|
||||||
finishedAt,
|
|
||||||
commands,
|
|
||||||
lastActivityAt,
|
|
||||||
}) => {
|
|
||||||
return {
|
|
||||||
_id,
|
|
||||||
user: user.username,
|
|
||||||
quest,
|
|
||||||
status,
|
|
||||||
createdAt: createdAt?.toLocaleString(),
|
|
||||||
finishedAt: finishedAt?.toLocaleString(),
|
|
||||||
lastActivityAt: lastActivityAt?.toLocaleString(),
|
|
||||||
usedTime: formatTime(lastActivityAt - createdAt),
|
|
||||||
commandCount: commands?.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sessionDetail(id) {
|
|
||||||
const session = await Session.findById(id).populate('user').exec()
|
|
||||||
const commands = (await Command.find({ session: id })).map(
|
|
||||||
({ command, pwd, output, error, createdAt, stage }) => ({
|
|
||||||
command: command?.slice(0, 20),
|
|
||||||
pwd,
|
|
||||||
stage,
|
|
||||||
output: output?.slice(0, 50),
|
|
||||||
error: error?.slice(0, 50),
|
|
||||||
createdAt: createdAt?.toLocaleString(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const { _id, user, quest, status, createdAt, finishedAt } = session
|
|
||||||
return {
|
|
||||||
_id,
|
|
||||||
user: user.username,
|
|
||||||
quest,
|
|
||||||
status,
|
|
||||||
createdAt: createdAt?.toLocaleString(),
|
|
||||||
finishedAt: finishedAt?.toLocaleString(),
|
|
||||||
usedTime: finishedAt ? formatTime(finishedAt - createdAt) : '',
|
|
||||||
commands,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Command, Session } from '@linux-odyssey/models'
|
||||||
|
import Pagination from './pagination.js'
|
||||||
|
import {
|
||||||
|
CommandObject,
|
||||||
|
SessionDetail,
|
||||||
|
SessionDocument,
|
||||||
|
SessionObject,
|
||||||
|
} from '../interface.js'
|
||||||
|
|
||||||
|
function formatTime(time: number) {
|
||||||
|
const seconds = Math.floor(time / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sessionList(
|
||||||
|
pagination: Pagination
|
||||||
|
): Promise<SessionObject[]> {
|
||||||
|
const sessions = await Session.aggregate([
|
||||||
|
pagination.match('_id'),
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: 'user',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'user',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: '$user' },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'commands',
|
||||||
|
localField: '_id',
|
||||||
|
foreignField: 'session',
|
||||||
|
as: 'commands',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pagination.sort('_id'),
|
||||||
|
pagination.limit(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return sessions.map((session) => {
|
||||||
|
const {
|
||||||
|
_id,
|
||||||
|
user,
|
||||||
|
quest,
|
||||||
|
status,
|
||||||
|
createdAt,
|
||||||
|
finishedAt,
|
||||||
|
commands,
|
||||||
|
lastActivityAt,
|
||||||
|
} = session
|
||||||
|
return {
|
||||||
|
_id,
|
||||||
|
user: user.username, // Assuming `user` is always populated.
|
||||||
|
quest,
|
||||||
|
status,
|
||||||
|
createdAt: createdAt?.toLocaleString(),
|
||||||
|
finishedAt: finishedAt?.toLocaleString(),
|
||||||
|
lastActivityAt: lastActivityAt?.toLocaleString(),
|
||||||
|
usedTime:
|
||||||
|
lastActivityAt && createdAt
|
||||||
|
? formatTime(lastActivityAt.getTime() - createdAt.getTime())
|
||||||
|
: 'N/A', // Ensure both dates are present
|
||||||
|
commandCount: commands?.length || 0,
|
||||||
|
commands,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sessionDetail(id: string): Promise<SessionDetail> {
|
||||||
|
const session = (await Session.findById(id)
|
||||||
|
.populate('user')
|
||||||
|
.exec()) as unknown as SessionDocument
|
||||||
|
const commands: CommandObject[] = (await Command.find({ session: id })).map(
|
||||||
|
({ command, pwd, output, error, createdAt, stage }): CommandObject => ({
|
||||||
|
command: command?.slice(0, 20),
|
||||||
|
pwd,
|
||||||
|
stage,
|
||||||
|
output: output?.slice(0, 50),
|
||||||
|
error: error?.slice(0, 50),
|
||||||
|
createdAt: createdAt?.toLocaleString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const { _id, user, quest, status, createdAt, finishedAt } = session
|
||||||
|
return {
|
||||||
|
_id,
|
||||||
|
user: user.username,
|
||||||
|
quest,
|
||||||
|
status,
|
||||||
|
createdAt: createdAt?.toLocaleString(),
|
||||||
|
finishedAt: finishedAt?.toLocaleString(),
|
||||||
|
usedTime: finishedAt
|
||||||
|
? formatTime(finishedAt.getTime() - createdAt.getTime())
|
||||||
|
: '',
|
||||||
|
commands,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Session, User } from '@linux-odyssey/models'
|
|
||||||
|
|
||||||
function loginMethods(user) {
|
|
||||||
const methods = []
|
|
||||||
if (user.hashedPassword) methods.push('password')
|
|
||||||
if (user.socialLogins) methods.push(...Object.keys(user.socialLogins))
|
|
||||||
return methods
|
|
||||||
}
|
|
||||||
|
|
||||||
export function userCount() {
|
|
||||||
return User.find().count()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function userList(pagination) {
|
|
||||||
const users = await Session.aggregate([
|
|
||||||
pagination.match('user'),
|
|
||||||
{
|
|
||||||
$group: {
|
|
||||||
_id: '$user',
|
|
||||||
count: { $sum: 1 },
|
|
||||||
lastActivityAt: { $max: '$lastActivityAt' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: 'users',
|
|
||||||
localField: '_id',
|
|
||||||
foreignField: '_id',
|
|
||||||
as: 'userData',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ $unwind: '$userData' },
|
|
||||||
pagination.sort('userData._id'),
|
|
||||||
pagination.limit(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return users.map(({ userData, count, lastActivityAt }) => {
|
|
||||||
const { _id, username, email, createdAt } = userData
|
|
||||||
return {
|
|
||||||
_id,
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
createdAt: createdAt?.toLocaleString(),
|
|
||||||
method: loginMethods(userData),
|
|
||||||
sessionCount: count,
|
|
||||||
lastActivityAt: lastActivityAt?.toLocaleString(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Session, User } from '@linux-odyssey/models'
|
||||||
|
import { Types } from 'mongoose'
|
||||||
|
import Pagination from './pagination.js'
|
||||||
|
import { IUser, UserSessionDetail } from '../interface.js'
|
||||||
|
|
||||||
|
function loginMethods(user: IUser): string[] {
|
||||||
|
const methods: string[] = []
|
||||||
|
if (user.hashedPassword) methods.push('password')
|
||||||
|
if (user.socialLogins && typeof user.socialLogins === 'object') {
|
||||||
|
methods.push(...Object.keys(user.socialLogins))
|
||||||
|
}
|
||||||
|
return methods
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: number) {
|
||||||
|
const seconds = Math.floor(time / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||||
|
}
|
||||||
|
export function userCount() {
|
||||||
|
return User.countDocuments({})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userList(pagination: Pagination) {
|
||||||
|
const users = await Session.aggregate([
|
||||||
|
pagination.match('user'),
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$user',
|
||||||
|
count: { $sum: 1 },
|
||||||
|
lastActivityAt: { $max: '$lastActivityAt' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: '_id',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'userData',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: '$userData' },
|
||||||
|
pagination.sort('userData._id'),
|
||||||
|
pagination.limit(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return users.map(({ userData, count, lastActivityAt }) => {
|
||||||
|
const { _id, username, email, createdAt } = userData
|
||||||
|
return {
|
||||||
|
_id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
createdAt: createdAt?.toLocaleString(),
|
||||||
|
method: loginMethods(userData),
|
||||||
|
sessionCount: count,
|
||||||
|
lastActivityAt: lastActivityAt?.toLocaleString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userDetail(
|
||||||
|
id: Types.ObjectId
|
||||||
|
): Promise<UserSessionDetail[]> {
|
||||||
|
const sessions = await Session.find({
|
||||||
|
user: id,
|
||||||
|
})
|
||||||
|
return sessions.map((session): UserSessionDetail => {
|
||||||
|
const { _id, quest, status, createdAt, lastActivityAt } = session
|
||||||
|
return {
|
||||||
|
_id,
|
||||||
|
quest,
|
||||||
|
status,
|
||||||
|
createdAt: createdAt?.toLocaleString(),
|
||||||
|
usedTime:
|
||||||
|
lastActivityAt && createdAt
|
||||||
|
? formatTime(lastActivityAt.getTime() - createdAt.getTime())
|
||||||
|
: 'N/A', // Ensure both dates are present
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idToUser(id: Types.ObjectId): Promise<String> {
|
||||||
|
const user = await User.findOne({
|
||||||
|
_id: id,
|
||||||
|
})
|
||||||
|
return user?.username || 'User not found'
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
import { homeController } from './controllers/index.js'
|
import { homeController } from './controllers/index.js'
|
||||||
import { userListController } from './controllers/users.js'
|
import {
|
||||||
|
userListController,
|
||||||
|
userDetailController,
|
||||||
|
} from './controllers/users.js'
|
||||||
import { questListController } from './controllers/quests.js'
|
import { questListController } from './controllers/quests.js'
|
||||||
import { commandListController } from './controllers/commands.js'
|
import { commandListController } from './controllers/commands.js'
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +16,7 @@ const router = Router()
|
|||||||
|
|
||||||
router.get('/', homeController)
|
router.get('/', homeController)
|
||||||
router.get('/users', userListController)
|
router.get('/users', userListController)
|
||||||
|
router.get('/users/:id', userDetailController)
|
||||||
router.get('/quests', questListController)
|
router.get('/quests', questListController)
|
||||||
router.get('/commands', commandListController)
|
router.get('/commands', commandListController)
|
||||||
router.get('/sessions', sessionListController)
|
router.get('/sessions', sessionListController)
|
||||||
File renamed without changes.
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "./dist"
|
"outDir": "./dist"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"references": [{ "path": "../models" }],
|
||||||
|
"ts-node": {
|
||||||
|
"swc": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
<h1>Commands</h1>
|
<h1 class='heading'>Commands</h1>
|
||||||
<div>
|
<div>
|
||||||
<h2>Error commands</h2>
|
<h2 class='heading'>Error commands</h2>
|
||||||
<div class='pagination'>
|
<div class='sort'>
|
||||||
<a href='/commands?order=asc'>Ascending</a>
|
<a href='/commands?order=asc'><div class='btn'>
|
||||||
<a href='/commands?order=desc'>Descending</a>
|
<h3>Ascending</h3>
|
||||||
|
</div></a>
|
||||||
|
<a href='/commands?order=desc'><div class='btn'>
|
||||||
|
<h3>Descending</h3>
|
||||||
|
</div></a>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table class='data-table'>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Command</th>
|
<th>Command</th>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
@@ -25,7 +29,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</table>
|
</table>
|
||||||
<div class='pagination'>
|
<div class='pagination sort'>
|
||||||
<a href='/commands?nextKey={{nextKey}}&order={{order}}'>Next</a>
|
<a href='/commands?nextKey={{nextKey}}&order={{order}}'><div class='btn'>
|
||||||
|
<h3>Next</h3>
|
||||||
|
</div></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
<h1>Welcome to {{title}}</h1>
|
<body>
|
||||||
<div>
|
<div class='title'>
|
||||||
<h2>Overview</h2>
|
<img src='icon_totem.svg' class='styled-image' />
|
||||||
<table>
|
<h1>Welcome to {{title}}</h1>
|
||||||
|
</div>
|
||||||
|
<div class='container'>
|
||||||
|
<h2 class='heading'>Overview</h2>
|
||||||
|
<table class='data-table'>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Registered Users</th>
|
<th>Registered Users</th>
|
||||||
<td>{{userCount}}</td>
|
<td>{{userCount}}</td>
|
||||||
@@ -19,4 +23,5 @@
|
|||||||
<td>{{completedRate}}%</td>
|
<td>{{completedRate}}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</body>
|
||||||
@@ -4,14 +4,12 @@
|
|||||||
<link rel='stylesheet' href='/css/style.css' />
|
<link rel='stylesheet' href='/css/style.css' />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav class='nav'>
|
||||||
<ul>
|
<div><a href='/'>Home</a></div>
|
||||||
<li><a href='/'>Home</a></li>
|
<div><a href='/users'>Users</a></div>
|
||||||
<li><a href='/users'>Users</a></li>
|
<div><a href='/quests'>Quests</a></div>
|
||||||
<li><a href='/quests'>Quests</a></li>
|
<div><a href='/commands'>Commands</a></div>
|
||||||
<li><a href='/commands'>Commands</a></li>
|
<div><a href='/sessions'>Sessions</a></div>
|
||||||
<li><a href='/sessions'>Sessions</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
{{{body}}}
|
{{{body}}}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<h1>Quests</h1>
|
<h1 class='heading'>Quests</h1>
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table class='data-table'>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Id</th>
|
<th>Id</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<h1>{{title}}</h1>
|
{{#with session}}<h1 class='heading'>Session {{_id}}</h1>
|
||||||
<div>
|
<div>
|
||||||
{{#with session}}
|
|
||||||
<table>
|
<table class='data-table'>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Quest</th>
|
<th>Quest</th>
|
||||||
<td>{{quest}}</td>
|
<td>{{quest}}</td>
|
||||||
@@ -32,9 +32,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2>Commands</h2>
|
<h2 class='heading'>Commands</h2>
|
||||||
|
|
||||||
<table>
|
<table class='data-table'>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Stage</th>
|
<th>Stage</th>
|
||||||
<th>PWD</th>
|
<th>PWD</th>
|
||||||
@@ -54,5 +54,5 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</table>
|
</table>
|
||||||
{{/with}}
|
|
||||||
</div>
|
</div>{{/with}}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
<h1>Sessions</h1>
|
<h1 class='heading'>Sessions</h1>
|
||||||
<div class='pagination'>
|
<div class='sort'>
|
||||||
<a href='/sessions?order=asc'>Ascending</a>
|
<a href='/sessions?order=asc'><div class='btn'>
|
||||||
<a href='/sessions?order=desc'>Descending</a>
|
<h3>Ascending</h3>
|
||||||
|
</div></a>
|
||||||
|
<a href='/sessions?order=desc'><div class='btn'>
|
||||||
|
<h3>Descending</h3>
|
||||||
|
</div></a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table class='data-table'>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Quest</th>
|
<th>Quest</th>
|
||||||
@@ -29,6 +33,8 @@
|
|||||||
{{/each}}
|
{{/each}}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class='pagination'>
|
<div class='pagination sort'>
|
||||||
<a href='/sessions?nextKey={{nextKey}}&order={{order}}'>Next</a>
|
<a href='/sessions?nextKey={{nextKey}}&order={{order}}'><div class='btn'>
|
||||||
|
<h3>Next</h3>
|
||||||
|
</div></a>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
<h1>Users List</h1>
|
<h1 class='heading'>Users List</h1>
|
||||||
<div class='pagination'>
|
<div class='sort'>
|
||||||
<a href='/users?order=asc'>Ascending</a>
|
<a href='/users?order=asc'><div class='btn'>
|
||||||
<a href='/users?order=desc'>Descending</a>
|
<h3>Ascending</h3>
|
||||||
|
</div></a>
|
||||||
|
<a href='/users?order=desc'><div class='btn'>
|
||||||
|
<h3>Descending</h3>
|
||||||
|
</div></a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table class='data-table'>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Last Activity</th>
|
<th>Last Activity</th>
|
||||||
@@ -15,7 +19,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{#each users}}
|
{{#each users}}
|
||||||
<tr>
|
<tr>
|
||||||
<td href='/users/{{_id}}'>{{username}}</td>
|
<td><a href='/users/{{_id}}'>{{username}}</a></td>
|
||||||
<td>{{lastActivityAt}}</td>
|
<td>{{lastActivityAt}}</td>
|
||||||
<td>{{sessionCount}}</td>
|
<td>{{sessionCount}}</td>
|
||||||
<td>{{email}}</td>
|
<td>{{email}}</td>
|
||||||
@@ -25,6 +29,8 @@
|
|||||||
{{/each}}
|
{{/each}}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class='pagination'>
|
<div class='pagination sort'>
|
||||||
<a href='/users?nextKey={{nextKey}}&order={{order}}'>Next</a>
|
<a href='/users?nextKey={{nextKey}}&order={{order}}'><div class='btn'>
|
||||||
|
<h3>Next</h3>
|
||||||
|
</div></a>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<h1 class='heading'>{{user}}'s Sessions History</h1>
|
||||||
|
<div>
|
||||||
|
<table class='data-table'>
|
||||||
|
<tr>
|
||||||
|
<th>Quest</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Used Time</th>
|
||||||
|
<th>Session</th>
|
||||||
|
</tr>
|
||||||
|
{{#each sessions}}
|
||||||
|
<tr>
|
||||||
|
<td>{{quest}}</td>
|
||||||
|
<td>{{status}}</td>
|
||||||
|
<td>{{createdAt}}</td>
|
||||||
|
<td>{{usedTime}}</td>
|
||||||
|
<td><a href='/sessions/{{_id}}'>Detail</a></td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const passwordPolicy = {
|
export const passwordPolicy = {
|
||||||
minLength: 8,
|
minLength: 8,
|
||||||
minNumbers: 1,
|
minNumbers: 1,
|
||||||
@@ -6,6 +8,11 @@ export const passwordPolicy = {
|
|||||||
minSymbols: 0,
|
minSymbols: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export const getSessionsRequestSchema = z.object({
|
||||||
passwordPolicy,
|
questId: z.string().optional(),
|
||||||
}
|
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>
|
||||||
@@ -10,5 +10,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc -w"
|
"dev": "tsc -w"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user