Feat typescript frontend (#107)

There is still tons of errors in e2e. Fix it after merging.
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2024-03-28 21:22:42 +08:00
commit 2fb1315c5a
40 files changed
+411 -268

No files matched your search

+12
View File
@@ -48,6 +48,18 @@ module.exports = {
'no-use-before-define': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
// Allow missing file extensions
'import/extensions': [
+1 -1
View File
@@ -8,6 +8,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2 -1
View File
@@ -2,7 +2,6 @@
"name": "app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:docker": "API_TARGET=http://backend:3000 vite",
@@ -18,6 +17,7 @@
"@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"@types/dompurify": "^3.0.5",
"axios": "^1.6.0",
"dompurify": "^3.0.6",
"echarts": "^5.4.3",
@@ -33,6 +33,7 @@
"xterm-addon-fit": "^0.7.0"
},
"devDependencies": {
"@types/validator": "^13.11.9",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/compiler-sfc": "^3.3.13",
"autoprefixer": "^10.4.14",
@@ -1,4 +1,4 @@
export default {
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
+9 -61
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
@@ -27,7 +27,7 @@ const password = ref('')
const errorMessage = ref('')
const toast = useToast()
const availableMethods = ref({})
const availableMethods = ref<{ [key: string]: boolean }>({})
onMounted(async () => {
const res = await api.get('/auth/available-methods')
availableMethods.value = res.data
@@ -44,7 +44,7 @@ const success = () => {
router.push({ name: 'map' })
}
const error = (err) => {
const error = (err: string) => {
errorMessage.value = err
}
@@ -114,10 +114,7 @@ const handleChange = () => {
:icon="['fab', 'google']"
class="text-text-secondary px-3 text-lg"
/>
<span class="font-medium">
<!-- Continue with Google -->
以 Google 繼續
</span>
<span class="font-medium"> 以 Google 繼續 </span>
</a>
<a
id="GitHubLogin"
@@ -129,32 +126,17 @@ const handleChange = () => {
:icon="['fab', 'github']"
class="text-text-secondary px-3 text-lg"
/>
<span class="font-medium">
<!-- Continue with GitHub -->
以 GitHub 繼續
</span>
<span class="font-medium"> 以 GitHub 繼續 </span>
</a>
<p
id="or"
class="text-text-secondary flex justify-center mt-3"
v-if="type === 'login' || type === 'register'"
>
<!-- or -->
或
</p>
</div>
<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
type="text"
id="username"
@@ -176,17 +158,6 @@ const handleChange = () => {
</label>
</div>
<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
type="email"
id="email"
@@ -200,15 +171,6 @@ const handleChange = () => {
/>
</div>
<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
type="password"
id="password"
@@ -231,43 +193,29 @@ const handleChange = () => {
class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full"
type="submit"
>
<span v-if="type === 'login'">
<!-- Log In -->
登入
</span>
<span v-else>
<!-- Sign Up -->
註冊
</span>
<span v-if="type === 'login'"> 登入 </span>
<span v-else> 註冊 </span>
</button>
<p id="SwitchLoginRegister" class="text-text flex justify-center mt-3">
<span id="CheckRegistered" v-if="type === 'register'">
<!-- Already have an account? -->
已經有帳號了嗎?
<RouterLink
id="SwitchtoLogin"
class="text-text-primary font-bold"
to="/login"
>
<u>
<!-- Log in -->
登入
</u>
<u> 登入 </u>
</RouterLink></span
>
<span id="CheckRegistered" v-else-if="type === 'login'">
<!-- Don't have an account? -->
還沒有帳號嗎?
<RouterLink
id="SwitchtoRegister"
class="text-text-primary font-bold"
to="/register"
>
<u>
<!-- Sign up -->
註冊
</u>
<u> 註冊 </u>
</RouterLink></span
>
</p>
+2 -8
View File
@@ -1,13 +1,7 @@
<template>
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
<h1 id="CommandBook" class="inline text-text-primary font-xl">
<!-- Command Cheatsheets -->
指令之書
</h1>
<h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1>
<div id="cheatsheets">
<p class="text-text-secondary">
<!-- --Command List-- -->
-- --指令列表-- --
</p>
<p class="text-text-secondary">--指令列表--</p>
</div>
</template>
+1 -2
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { ref } from 'vue'
@@ -47,7 +47,6 @@ setTimeout(() => {
id="QuestCompleted"
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
>
<!-- Quest Completed! -->
關卡完成!
</h2>
<div class="z-10 right-1/2 top-1/2" @animationend="defineComponent">
+40 -53
View File
@@ -1,55 +1,4 @@
<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>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { createSession } from '../store/session'
@@ -64,9 +13,47 @@ const continuePlay = async () => {
try {
router.push({ name: 'map' })
} catch (err) {
// useToast().error('Failed to load map')
useToast().error('無法讀取地圖')
console.error(err)
}
}
</script>
<template>
<section class="h-[10%] p-3 grid grid-cols-4 gap-4 place-content-between">
<button id="survey" class="bg-bg-secondary rounded-lg p-2">
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary"
/>
<p class="text-text-primary inline ml-1">問卷</p>
</a>
</button>
<button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
<font-awesome-icon
:icon="['far', 'circle-question']"
class="text-text-disabled"
/>
<p class="text-text-disabled inline ml-1">解答</p>
</button>
<button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
<font-awesome-icon
:icon="['fas', 'arrow-rotate-left']"
class="text-text-primary"
/>
<p class="text-text-primary inline ml-1">重來</p>
</button>
<button
id="continue"
class="bg-bg-secondary rounded-lg p-2"
@click="continuePlay"
>
<font-awesome-icon
:icon="['far', 'circle-right']"
class="text-text-primary"
/>
<p class="text-text-primary inline ml-1">繼續</p>
</button>
</section>
</template>
+18 -7
View File
@@ -1,26 +1,34 @@
<script setup>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const cw = 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 maxColums = ref(0)
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)
}
const randomFloat = (min, max) => {
const randomFloat = (min: number, max: number) => {
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()
if (point.y < 0) point.speed = randomFloat(10, 20)
if (ctx.value === null) return
ctx.value.fillStyle = '#0F0'
ctx.value.font = `${fontSize.value}px san-serif`
ctx.value.fillText(point.value, point.x, point.y)
@@ -33,6 +41,7 @@ const draw = (point) => {
}
const update = () => {
if (ctx.value === null) return
ctx.value.fillStyle = 'rgba(0,0,0,0.05)'
ctx.value.fillRect(0, 0, cw.value, ch.value)
@@ -48,7 +57,7 @@ onMounted(() => {
ch.value = document.documentElement.clientHeight
maxColums.value = cw.value / fontSize.value
const canvas = document.querySelector('canvas')
const canvas = document.querySelector('canvas') as HTMLCanvasElement
ctx.value = canvas.getContext('2d')
canvas.width = cw.value
@@ -58,6 +67,8 @@ onMounted(() => {
fallingCharArr.value.push({
x: i * fontSize.value,
y: randomFloat(-500, 0),
value: '',
speed: 0,
})
}
+1 -1
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
const props = defineProps({
node: {
type: Object,
+1 -3
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { defineProps, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
@@ -21,7 +21,6 @@ const handleLogout = async () => {
reset()
router.push({ name: 'login' })
} catch (err) {
// toast.error('Failed to logout')
toast.error('登出失敗')
console.error(err)
}
@@ -38,7 +37,6 @@ onMounted(async () => {
try {
await loadUserProfile()
} catch (err) {
// toast.error('Failed to load user profile')
toast.error('無法讀取使用者資料')
}
})
+1 -1
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { ref, watch } from 'vue'
import sessionStore from '../store/session'
import MarkdownText from './MarkdownText.vue'
+1 -1
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { computed, defineProps } from 'vue'
import markdown2HTML from '../utils/markdown'
+9 -11
View File
@@ -1,5 +1,5 @@
<!-- eslint-disable no-console -->
<script setup>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
@@ -50,7 +50,7 @@ async function getProgress() {
}
}
const genOption = (nodes, edges) => ({
const genOption = (nodes: any, edges: any) => ({
renderer: 'svg',
tooltip: {},
animationDurationUpdate: 1500,
@@ -94,7 +94,7 @@ const genOption = (nodes, edges) => ({
curveness: 0.08,
},
itemStyle: {
color: (params) => {
color: (params: any) => {
const {
data: { completed, unlocked },
} = params
@@ -111,7 +111,7 @@ const genOption = (nodes, edges) => ({
],
})
function getOption(quests, progress) {
function getOption(quests: any, progress: any) {
const dag = new DAG(quests)
const nodes = dag.getNodes().map((node) => ({
@@ -120,9 +120,9 @@ function getOption(quests, progress) {
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),
unlocked: node.requirements.every((req: any) => progress[req]?.completed),
}))
const lineAppearence = (node) => {
const lineAppearence = (node: any) => {
try {
const { unlocked } = node
return {
@@ -134,7 +134,7 @@ function getOption(quests, progress) {
throw err
}
}
const edges = dag.getEdgesArray().map((edge) => ({
const edges = dag.getEdgesArray().map((edge: any) => ({
source: edge[0],
target: edge[1],
lineStyle: lineAppearence(nodes.find((node) => node.id === edge[1])),
@@ -145,10 +145,10 @@ function getOption(quests, progress) {
const router = useRouter()
const toast = useToast()
function initChart(option) {
function initChart(option: any) {
chartInstance = init(chartContainer.value)
chartInstance.setOption(option)
chartInstance.on('click', (params) => {
chartInstance.on('click', (params: any) => {
const {
data: { id, unlocked },
} = params
@@ -156,7 +156,6 @@ function initChart(option) {
if (unlocked) {
router.push({ name: 'game', params: { questId: id } })
} else {
// toast.warning('You have not completed the previous quest yet!')
toast.warning('你還沒完成前一個關卡!')
}
})
@@ -181,7 +180,6 @@ onMounted(async () => {
class="p-4 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
>
<!-- Get through your linux journey! -->
踏上你的Linux冒險之旅吧!
</h1>
<div class="flex flex-wrap absolute w-full h-full z-1">
+4 -6
View File
@@ -1,10 +1,11 @@
<script setup>
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownText from './MarkdownText.vue'
import sessionStore from '../store/session'
import { Task } from '../types'
const tasks = computed(() => {
return sessionStore.session.tasks.map((task) => {
return sessionStore.session.tasks.map((task: Task) => {
const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text'
return {
@@ -29,10 +30,7 @@ const tasks = computed(() => {
:content="sessionStore.quest.instruction"
/>
<br />
<p id="tasks" class="text-text">
<!-- Tasks: -->
任務:
</p>
<p id="tasks" class="text-text">任務:</p>
<ul v-if="sessionStore.session">
<li v-for="task in tasks" :key="task.id">
<MarkdownText :class="task.class" :content="task.content" />
+15 -17
View File
@@ -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>
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
<button id="Terminal" class="text-text h-[5%]">
<!-- Terminal -->
終端機
</button>
<button id="Terminal" class="text-text h-[5%]">終端機</button>
<div
id="terminal"
ref="terminal"
class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
></div>
</template>
<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>
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup>
<script setup lang="ts">
import FileNode from './FileNode.vue'
import sessionStore from '../store/session.js'
import sessionStore from '../store/session'
</script>
<template>
+8 -4
View File
@@ -1,19 +1,21 @@
<script setup>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from '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 }
function mouseMoveHandler(e) {
function mouseMoveHandler(e: MouseEvent) {
const dx = e.clientX - pos.x
const dy = e.clientY - pos.y
if (treeRef.value === null) return
treeRef.value.scrollTop = pos.top - dy
treeRef.value.scrollLeft = pos.left - dx
}
function mouseUpHandler() {
if (treeRef.value === null) return
treeRef.value.style.cursor = 'grab'
treeRef.value.style.removeProperty('user-select')
@@ -21,7 +23,8 @@ function 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.userSelect = 'none'
@@ -37,6 +40,7 @@ function mouseDownHandler(e) {
}
onMounted(() => {
if (treeRef.value === null) return
treeRef.value.style.cursor = 'grab'
treeRef.value.addEventListener('mousedown', mouseDownHandler)
})
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
+1 -1
View File
@@ -57,7 +57,7 @@ const router = createRouter({
routes,
})
router.beforeEach(async (to, from, next) => {
router.beforeEach(async (to, _from, next) => {
const loggedIn = await isLoggedIn()
// Check if the route requires authentication
if (to.meta.requiresAuth && !loggedIn) {
@@ -1,10 +1,13 @@
import { reactive } from 'vue'
import { useToast } from 'vue-toastification'
import { FileGraph } from '@linux-odyssey/file-graph'
import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
// eslint-disable-next-line import/no-relative-packages
import { IQuest } from '../../../packages/models/src/quest'
import api from '../utils/api'
import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal'
import { LoadQuestError, LoadSessionError } from '../utils/errors'
import { Session, StageResponse } from '../types'
const toast = useToast()
@@ -15,6 +18,7 @@ function newSession() {
discovered: false,
})
return {
_id: '',
status: 'inactive',
graph,
pwd: '',
@@ -24,7 +28,13 @@ function newSession() {
}
}
const store = reactive({
interface Store {
session: Session
questId: string
quest: IQuest | null
}
const store = reactive<Store>({
session: newSession(),
questId: '',
quest: null,
@@ -33,14 +43,14 @@ const store = reactive({
const socket = new Socket()
const term = new SocketTerminal(40, 80)
function setQuest(questId) {
function setQuest(questId: string) {
return api.get(`/quests/${questId}`).then((res) => {
store.questId = res.data._id
store.quest = res.data
})
}
async function setSession(session) {
async function setSession(session: Session) {
try {
console.log('Setting session...', session)
store.session = session
@@ -48,9 +58,13 @@ async function setSession(session) {
term.reset()
await socket.connect(session)
term.focus()
} catch (err) {
} catch (err: any) {
console.error(err)
toast.error(err.message)
if (err instanceof Error) {
toast.error(err.message)
} else {
toast.error('Failed to connect to the session. Please try again later.')
}
throw err
}
}
@@ -67,7 +81,7 @@ export async function createSession() {
}
}
async function getActiveSession(questId) {
async function getActiveSession(questId: string) {
try {
const res = await api.post('/sessions/active', { questId })
await setSession(res.data)
@@ -78,7 +92,7 @@ async function getActiveSession(questId) {
}
}
function updateGraph(event) {
function updateGraph(event: { discover: FileObject[]; pwd: string }) {
if (event.discover) {
store.session.graph.discover(event.discover)
}
@@ -87,10 +101,10 @@ function updateGraph(event) {
}
}
function newResponse(response) {
function newResponse(response: StageResponse) {
console.log('newResponse', response, 'session', store.session)
store.session.responses.push(response.responses)
store.session.hints.push(response.hints)
store.session.responses.push(...response.responses)
store.session.hints.push(...response.hints)
store.session.tasks = response.tasks
store.session.status = response.status
}
@@ -106,7 +120,7 @@ export function reset() {
store.quest = null
}
export async function init(questId) {
export async function init(questId: string) {
if (!questId) throw new Error('No quest ID provided')
reset()
try {
@@ -124,16 +138,16 @@ export async function init(questId) {
}
function setup() {
socket.on('terminal', (data) => {
socket.on('terminal', (data: string) => {
term.write(data)
})
term.onData((data) => {
term.onData((data: string) => {
socket.emit('terminal', data)
})
socket.on('graph', (event) => {
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
updateGraph(event)
})
socket.on('response', (response) => {
socket.on('response', (response: StageResponse) => {
newResponse(response)
})
}
@@ -15,7 +15,7 @@ export async function loadUserProfile() {
store.email = email
store.progress = progress
return store
} catch (err) {
} catch (err: any) {
console.error(err)
throw new Error(`Failed to load user profile: ${err.message}`)
}
+26
View File
@@ -0,0 +1,26 @@
import { FileGraph } from '@linux-odyssey/file-graph'
// eslint-disable-next-line import/no-relative-packages
import { IResponse } from '../../packages/models/src/response'
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[]
}
File renamed without changes.
@@ -12,7 +12,12 @@ export async function isLoggedIn() {
return loggedIn
}
function handleRequestError(err) {
function handleRequestError(err: {
response: {
data?: any
status?: number
}
}) {
if (!err.response) throw err // Not an axios error
const { status } = err.response
if (status === 429) {
@@ -41,14 +46,14 @@ function handleRequestError(err) {
throw err
}
export const login = (username, password) => {
export const login = (username: string, password: string) => {
return api
.post('/auth/login', { username, password })
.then(() => true)
.catch(handleRequestError)
}
export const register = (username, email, password) => {
export const register = (username: string, email: string, password: string) => {
return api
.post('/auth/register', {
username,
@@ -59,7 +64,7 @@ export const register = (username, email, password) => {
.catch(handleRequestError)
}
export const checkUsername = async (username) => {
export const checkUsername = async (username: string) => {
return api
.get('/auth/check-username', {
params: { username },
@@ -68,7 +73,7 @@ export const checkUsername = async (username) => {
.catch(handleRequestError)
}
export const chooseUsername = async (username) => {
export const chooseUsername = async (username: string) => {
return api
.post('/auth/register-from-session', { username })
.then(() => true)
@@ -1,27 +1,28 @@
/* eslint-disable max-classes-per-file */
export class TooManyRequestsError extends Error {
constructor(message) {
constructor(message: string) {
super(message)
this.name = 'TooManyRequestsError'
}
}
export class ValidationError extends Error {
constructor(message) {
constructor(message: string) {
super(message)
this.name = 'ValidationError'
}
}
export class UnauthorizedError extends Error {
constructor(message) {
constructor(message: string) {
super(message)
this.name = 'UnauthorizedError'
}
}
export class LoadQuestError extends Error {
constructor(message, questId) {
questId: string
constructor(message: string, questId: string) {
super(message)
this.name = 'LoadQuestError'
this.questId = questId
@@ -29,7 +30,8 @@ export class LoadQuestError extends Error {
}
export class LoadSessionError extends Error {
constructor(message, questId) {
questId: string
constructor(message: string, questId: string) {
super(message)
this.name = 'LoadSessionError'
this.questId = questId
@@ -1,7 +1,7 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
export default function markdown2HTML(markdown) {
export default function markdown2HTML(markdown: string) {
const html = marked.parse(markdown)
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 {
socket: Socket | null
listeners: { event: string; callback: SocketCallback }[]
constructor() {
this.socket = null
this.listeners = []
}
connect(session) {
return new Promise((resolve, reject) => {
connect(session: Session) {
return new Promise<void>((resolve, reject) => {
this.disconnect()
this.socket = io('', {
@@ -28,7 +33,7 @@ class SocketWrapper {
})
}
on(event, callback) {
on(event: string, callback: SocketCallback) {
this.listeners.push({ event, callback })
if (this.socket) {
this.socket.on(event, callback)
@@ -36,12 +41,14 @@ class SocketWrapper {
}
bindListeners() {
this.listeners.forEach(({ event, callback }) => {
this.socket.on(event, callback)
})
this.listeners.forEach(
({ 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) {
console.warn('Socket is not connected!')
return
@@ -3,7 +3,10 @@ import { FitAddon } from 'xterm-addon-fit'
import 'xterm/css/xterm.css'
class SocketTerminal {
constructor(rows, cols) {
private term: Terminal
private fitAddon: FitAddon
constructor(rows: number, cols: number) {
this.term = new Terminal({
rendererType: 'canvas', // 渲染类型
rows, // 行数
@@ -24,29 +27,29 @@ class SocketTerminal {
this.fitAddon.fit()
}
write(message) {
write(message: string): void {
this.term.write(message)
}
onData(callback) {
onData(callback: any): void {
this.term.onData(callback)
}
focus() {
focus(): void {
this.term.focus()
}
resizeScreen() {
resizeScreen(): void {
this.fitAddon.fit()
}
mount(reference) {
mount(reference: HTMLElement): void {
this.term.open(reference)
this.term.focus()
this.resizeScreen()
}
reset() {
reset(): void {
this.term.reset()
}
}
+20 -8
View File
@@ -1,14 +1,20 @@
<script setup>
<script setup lang="ts">
import { isValidUsername } from '@linux-odyssey/utils'
import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue'
import { TooManyRequestsError, ValidationError } from '../utils/errors'
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 (!isValidUsername(username)) {
// error('Invalid username.')
error('無效的帳號名稱')
return
}
@@ -16,7 +22,6 @@ async function check({ username, error }) {
await checkUsername(username)
} catch (err) {
if (err instanceof TooManyRequestsError) {
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
@@ -24,19 +29,26 @@ async function check({ username, error }) {
error(err.message)
return
}
// error('Something went wrong. Please try again later.')
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 {
await chooseUsername(username)
success()
} catch (err) {
} catch (err: any) {
if (err instanceof TooManyRequestsError) {
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
+2 -6
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import sessionStore, { init } from '../store/session'
@@ -28,16 +28,12 @@ const toast = useToast()
onMounted(async () => {
try {
await init(props.questId)
} catch (err) {
} catch (err: any) {
if (err instanceof LoadQuestError) {
// 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
}
+9 -3
View File
@@ -1,18 +1,24 @@
<script setup>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import DynamicBackground from '../components/DynamicBackground.vue'
import GameHeaderPart from '../components/GameHeaderPart.vue'
import api from '../utils/api'
const leaderboard = ref([])
const leaderboard = ref<
{
username: string
score: number
completedQuests: string[]
}[]
>([])
const toast = useToast()
async function getLeaderboard() {
try {
const res = await api.get('/leaderboard')
leaderboard.value = res.data
} catch (err) {
} catch (err: any) {
console.error(err)
toast.error('讀取排行榜失敗')
}
+13 -6
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue'
@@ -9,28 +9,35 @@ import {
} from '../utils/errors'
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 {
const isSuccess = await login(username, password)
if (isSuccess) success()
} catch (err) {
if (err instanceof TooManyRequestsError) {
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof UnauthorizedError) {
// error('Wrong username or password.')
error('錯誤的帳號名稱或密碼。')
return
}
if (err instanceof ValidationError) {
// error('Invalid username or password.')
error('無效的帳號名稱或密碼。')
return
}
console.error(err)
// error('Something went wrong.')
error('出了點問題。')
}
}
+1 -8
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import QuestMap from '../components/QuestMap.vue'
import GameHeaderPart from '../components/GameHeaderPart.vue'
</script>
@@ -6,13 +6,6 @@ import GameHeaderPart from '../components/GameHeaderPart.vue'
<template>
<div class="h-[6vh] w-full">
<GameHeaderPart title="關卡地圖" />
<!-- <p
class="text-text-primary absolute z-1 font-bold inline-block tracking-widest"
style="left: 1.2%; top: 1.5%; height: 100%; font-size: 2vh"
>
Quest Map
Unix編年史
</p> -->
</div>
<div class="flex flex-col justify-center items-center w-full h-[94vh]">
<QuestMap />
+27 -13
View File
@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import validator from 'validator'
import { isValidUsername } from '@linux-odyssey/utils'
import { passwordPolicy } from '@linux-odyssey/constants'
@@ -12,17 +12,28 @@ import {
ValidationError,
} 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)
.then(success)
.catch((err) => {
if (err instanceof TooManyRequestsError) {
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof UnauthorizedError) {
// error('Invalid username or password.')
error('無效的帳號名稱或密碼。')
return
}
@@ -31,26 +42,31 @@ function handleRegister({ username, email, password, success, error }) {
return
}
console.error(err)
// error('Something went wrong.')
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)) {
// error('Invalid username.')
error('無效的帳號名稱')
return
}
if (email && !validator.isEmail(email)) {
// error('Invalid email.')
error('無效的電子郵件')
return
}
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個字元,至少包含一個數字、一個大寫及一個小寫。')
return
}
@@ -62,12 +78,10 @@ async function check({ username, email, password, error }) {
return
}
if (err instanceof TooManyRequestsError) {
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
console.error(err)
// error('Something went wrong.')
error('出了點問題。')
}
}
File renamed without changes.
+3
View File
@@ -51,5 +51,8 @@
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"dependencies": {
"typescript-eslint": "^7.4.0"
}
}
+107
View File
@@ -1043,6 +1043,13 @@
"@types/docker-modem" "*"
"@types/node" "*"
"@types/dompurify@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7"
integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==
dependencies:
"@types/trusted-types" "*"
"@types/dotenv@^8.2.0":
version "8.2.0"
resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-8.2.0.tgz#5cd64710c3c98e82d9d15844375a33bf1b45d053"
@@ -1307,6 +1314,11 @@
resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c"
integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==
"@types/trusted-types@*":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
"@types/validator@^13.11.9":
version "13.11.9"
resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.11.9.tgz#adfe96520b437a0eaa798a475877bf2f75ee402d"
@@ -1343,6 +1355,23 @@
dependencies:
"@types/node" "*"
"@typescript-eslint/eslint-plugin@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz#de61c3083842fc6ac889d2fc83c9a96b55ab8328"
integrity sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw==
dependencies:
"@eslint-community/regexpp" "^4.5.1"
"@typescript-eslint/scope-manager" "7.4.0"
"@typescript-eslint/type-utils" "7.4.0"
"@typescript-eslint/utils" "7.4.0"
"@typescript-eslint/visitor-keys" "7.4.0"
debug "^4.3.4"
graphemer "^1.4.0"
ignore "^5.2.4"
natural-compare "^1.4.0"
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/eslint-plugin@^6.14.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz#30830c1ca81fd5f3c2714e524c4303e0194f9cd3"
@@ -1360,6 +1389,17 @@
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/parser@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.4.0.tgz#540f4321de1e52b886c0fa68628af1459954c1f1"
integrity sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ==
dependencies:
"@typescript-eslint/scope-manager" "7.4.0"
"@typescript-eslint/types" "7.4.0"
"@typescript-eslint/typescript-estree" "7.4.0"
"@typescript-eslint/visitor-keys" "7.4.0"
debug "^4.3.4"
"@typescript-eslint/parser@^6.14.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b"
@@ -1379,6 +1419,14 @@
"@typescript-eslint/types" "6.21.0"
"@typescript-eslint/visitor-keys" "6.21.0"
"@typescript-eslint/scope-manager@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz#acfc69261f10ece7bf7ece1734f1713392c3655f"
integrity sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==
dependencies:
"@typescript-eslint/types" "7.4.0"
"@typescript-eslint/visitor-keys" "7.4.0"
"@typescript-eslint/type-utils@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz#6473281cfed4dacabe8004e8521cee0bd9d4c01e"
@@ -1389,11 +1437,26 @@
debug "^4.3.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/type-utils@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.4.0.tgz#cfcaab21bcca441c57da5d3a1153555e39028cbd"
integrity sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw==
dependencies:
"@typescript-eslint/typescript-estree" "7.4.0"
"@typescript-eslint/utils" "7.4.0"
debug "^4.3.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/types@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d"
integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==
"@typescript-eslint/types@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.4.0.tgz#ee9dafa75c99eaee49de6dcc9348b45d354419b6"
integrity sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==
"@typescript-eslint/typescript-estree@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46"
@@ -1408,6 +1471,20 @@
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/typescript-estree@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz#12dbcb4624d952f72c10a9f4431284fca24624f4"
integrity sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==
dependencies:
"@typescript-eslint/types" "7.4.0"
"@typescript-eslint/visitor-keys" "7.4.0"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
minimatch "9.0.3"
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/utils@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.21.0.tgz#4714e7a6b39e773c1c8e97ec587f520840cd8134"
@@ -1421,6 +1498,19 @@
"@typescript-eslint/typescript-estree" "6.21.0"
semver "^7.5.4"
"@typescript-eslint/utils@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.4.0.tgz#d889a0630cab88bddedaf7c845c64a00576257bd"
integrity sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg==
dependencies:
"@eslint-community/eslint-utils" "^4.4.0"
"@types/json-schema" "^7.0.12"
"@types/semver" "^7.5.0"
"@typescript-eslint/scope-manager" "7.4.0"
"@typescript-eslint/types" "7.4.0"
"@typescript-eslint/typescript-estree" "7.4.0"
semver "^7.5.4"
"@typescript-eslint/visitor-keys@6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47"
@@ -1429,6 +1519,14 @@
"@typescript-eslint/types" "6.21.0"
eslint-visitor-keys "^3.4.1"
"@typescript-eslint/visitor-keys@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz#0c8ff2c1f8a6fe8d7d1a57ebbd4a638e86a60a94"
integrity sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==
dependencies:
"@typescript-eslint/types" "7.4.0"
eslint-visitor-keys "^3.4.1"
"@ungap/structured-clone@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
@@ -6936,6 +7034,15 @@ typed-array-length@^1.0.4:
is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0"
typescript-eslint@^7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-7.4.0.tgz#2a9b35e99a6292e59ff72bce3ef56e10d8b20b40"
integrity sha512-8GYQsb/joknlAZEAs/kqonfrsAc98C5DoellmwHREPqKwSTKSY2YB93IwmvNuX6+WE5QkKc31X9wHo/UcpYXpw==
dependencies:
"@typescript-eslint/eslint-plugin" "7.4.0"
"@typescript-eslint/parser" "7.4.0"
"@typescript-eslint/utils" "7.4.0"
typescript@^5.3.3:
version "5.3.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"