Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b4f50ebbd | ||
|
|
5fd24190d2 | ||
|
|
d2bb079f50 | ||
|
|
d959ab22fe | ||
|
|
9c8260a411 | ||
|
|
3c92c9d143 | ||
|
|
5208d87bd6 | ||
|
|
634464065d | ||
|
|
4c90793fc6 | ||
|
|
169587c54d | ||
|
|
4dd5f94c60 | ||
|
|
14bcb3b0d5 | ||
|
|
8281a9d882 | ||
|
|
dcf72d046c | ||
|
|
b73607fb8d | ||
|
|
b64d707d16 | ||
|
|
7dcac306f9 | ||
|
|
eddbda23d7 | ||
|
|
124c5cc401 | ||
|
|
21d08b8156 | ||
|
|
3745011504 | ||
|
|
3f08452ea4 | ||
|
|
acfb09c6ca | ||
|
|
c2c100e6e6 | ||
|
|
4033eefa0d | ||
|
|
334b34ef65 | ||
|
|
ac89d521fc | ||
|
|
60641ba0ad | ||
|
|
dd67bcb1bd | ||
|
|
d002b21aa8 | ||
|
|
033c808c02 | ||
|
|
1be4f73e7e | ||
|
|
9ddfcd2a86 | ||
|
|
ca36f6391a | ||
|
|
1572b2ea06 | ||
|
|
8f6914d46c | ||
|
|
517ab43e8c | ||
|
|
74c49e1df7 | ||
|
|
cb0f3cc3b0 | ||
|
|
5605b27127 | ||
|
|
a7368110dd | ||
|
|
bdb7def73a | ||
|
|
b74866e50f | ||
|
|
d577656eb1 | ||
|
|
6400aa4a56 | ||
|
|
887d205ec1 | ||
|
|
8597c22b9f | ||
|
|
d8c0ee4b20 | ||
|
|
8180e9a0cf | ||
|
|
6daa11bff7 | ||
|
|
976dc6363c | ||
|
|
8d0a210f0e | ||
|
|
c38104d043 | ||
|
|
83e563ab56 | ||
|
|
fa2059814c | ||
|
|
f9fa5a2102 | ||
|
|
8ed6acbcc1 | ||
|
|
d2452c91a6 | ||
|
|
5bac9ce099 | ||
|
|
2aaff0f7bc | ||
|
|
96ab39437b | ||
|
|
d6b606e807 | ||
|
|
b792992391 | ||
|
|
922100d8da | ||
|
|
52597e8c5e | ||
|
|
a6c160ac0e | ||
|
|
3859403b56 | ||
|
|
2a29b0b8ac | ||
|
|
aaa97e0dca | ||
|
|
3a28af0a67 | ||
|
|
551d502845 | ||
|
|
3f0191112d | ||
|
|
d565709a59 | ||
|
|
435a426f5b | ||
|
|
377afb3078 | ||
|
|
9d26951978 | ||
|
|
db3b00f8ee | ||
|
|
c09c4ff9e0 | ||
|
|
b3b374b3cd | ||
|
|
5ca187651d | ||
|
|
f2ca4a26ac | ||
|
|
462f17a779 | ||
|
|
3deabb25a9 | ||
|
|
3dad7ddccb | ||
|
|
25888a1465 | ||
|
|
3559814f55 | ||
|
|
8142ff816c | ||
|
|
9c160253c9 | ||
|
|
80e1a40b6e | ||
|
|
2b70f3c7cd | ||
|
|
2e29919ad9 | ||
|
|
ca79042a3b | ||
|
|
c8d2320bf5 | ||
|
|
441909efd5 | ||
|
|
90e3fc2bd0 | ||
|
|
598141d735 | ||
|
|
e11432be45 | ||
|
|
2c1fe7bb96 | ||
|
|
8c54bfe192 | ||
|
|
d292195302 | ||
|
|
6bbf70a258 | ||
|
|
526b856de7 | ||
|
|
1a20ffb295 | ||
|
|
4efa9b8831 | ||
|
|
3944e91d0c | ||
|
|
c30b4a7c6e | ||
|
|
14cd16b611 | ||
|
|
b07b60f6bd | ||
|
|
9d1bed7543 | ||
|
|
715352f77c | ||
|
|
c4550a395a | ||
|
|
5795cb16b8 | ||
|
|
691a6dcb25 | ||
|
|
979ea72f3a | ||
|
|
f9e40edb8a | ||
|
|
276bfee5ed | ||
|
|
f03c359f89 | ||
|
|
7db08a5cda | ||
|
|
b19f0da678 | ||
|
|
c87eef233c | ||
|
|
dabc40eb1e | ||
|
|
0efe2235f8 | ||
|
|
9d45993d0d | ||
|
|
99871fa299 | ||
|
|
248271355f | ||
|
|
79f9cb3e95 | ||
|
|
47c7ff04a9 | ||
|
|
f831f4d4e5 | ||
|
|
29799faa79 | ||
|
|
2727c9f4bb | ||
|
|
988cb65b51 | ||
|
|
4324932fc9 | ||
|
|
e9b49502ab | ||
|
|
d5519e7157 | ||
|
|
ed596c245d | ||
|
|
57c080d88b | ||
|
|
d795dbd788 | ||
|
|
218d433c14 | ||
|
|
48835f8830 | ||
|
|
9ed726d597 | ||
|
|
fcb52b9d9e | ||
|
|
dcd8f441f8 | ||
|
|
94ef3749f5 | ||
|
|
3398d18055 | ||
|
|
c64043316e | ||
|
|
7cdc542c19 | ||
|
|
78b9529699 | ||
|
|
8a31a58c56 | ||
|
|
103ce4774c | ||
|
|
4e6602d82f | ||
|
|
2eaf3c8f24 | ||
|
|
15465feda9 | ||
|
|
ea2ba2c7a0 | ||
|
|
0b136a830a | ||
|
|
3848ca6941 | ||
|
|
443e810471 | ||
|
|
198dcae3f8 | ||
|
|
7347c37a9e | ||
|
|
1d725d82ae | ||
|
|
b5c601f4a9 | ||
|
|
5069ac12da | ||
|
|
cec4d86058 | ||
|
|
93b0bae204 | ||
|
|
4713b11c95 | ||
|
|
36e6bc10d3 | ||
|
|
580d5fc3f8 | ||
|
|
d8392ec3f6 | ||
|
|
5f99f42f5f | ||
|
|
bdf757c2d4 | ||
|
|
453bfb7f1e | ||
|
|
3b470ed952 | ||
|
|
b4af2d46d3 | ||
|
|
0e96e30b96 | ||
|
|
3e3ac58c6b | ||
|
|
9f690548cf | ||
|
|
cd2ec6df75 | ||
|
|
8e7b5f2552 | ||
|
|
385c6bc110 | ||
|
|
092cf284dd | ||
|
|
7806852cab | ||
|
|
1e31cf3be5 | ||
|
|
aa903fbacf | ||
|
|
df980a7d56 | ||
|
|
980d0497f4 | ||
|
|
e37a23c34f | ||
|
|
f4fc6de4cc | ||
|
|
9bfc6614ca | ||
|
|
8c4a9ea871 | ||
|
|
ad29137b86 | ||
|
|
b5e49547ea | ||
|
|
9a4f9b958a | ||
|
|
29602785cd | ||
|
|
bf44cce580 | ||
|
|
7c88a60d94 | ||
|
|
1f55ae09d6 | ||
|
|
e2b5bb3b70 | ||
|
|
ba69685ea2 | ||
|
|
84e65c9ef6 | ||
|
|
f466a7a793 | ||
|
|
d74a549b89 | ||
|
|
33a1a110e8 | ||
|
|
d4c9d4577c | ||
|
|
27b6096633 | ||
|
|
a8a367f477 | ||
|
|
78b92ee8ce | ||
|
|
14fb870838 | ||
|
|
455fde41ab | ||
|
|
ff1cdc9a95 | ||
|
|
df140c64f8 | ||
|
|
4efd670ed6 | ||
|
|
4814626a71 | ||
|
|
1179faf176 | ||
|
|
a9acf54139 | ||
|
|
b64f49915f | ||
|
|
ef279ba00e | ||
|
|
eb106c020a | ||
|
|
432084cc66 | ||
|
|
2fb1315c5a | ||
|
|
75f5407dde | ||
|
|
ebd713200c | ||
|
|
bb2a1a145f | ||
|
|
7f33100132 | ||
|
|
59a0865157 | ||
|
|
c843a03fc8 | ||
|
|
182cc7d1bb | ||
|
|
daa3f11b2e | ||
|
|
a4c121dc94 | ||
|
|
5adaa47c5c | ||
|
|
3345fb3b3f | ||
|
|
0313671ff5 | ||
|
|
e31511fdc1 | ||
|
|
685be8c1d2 | ||
|
|
0d2e62be24 | ||
|
|
962db4e8a4 | ||
|
|
52d8e86043 | ||
|
|
51fb25aa50 | ||
|
|
d2d8374926 | ||
|
|
4570162e7b | ||
|
|
3194c03f48 | ||
|
|
c4fd92e289 | ||
|
|
ce538a049c | ||
|
|
36932e1eb1 | ||
|
|
ca3d9b4236 | ||
|
|
76e6cda0cb | ||
|
|
3dee3d0373 | ||
|
|
218a25daee | ||
|
|
94ae2d7df4 | ||
|
|
a71411d5bf | ||
|
|
807974d727 | ||
|
|
ec63bba0fe | ||
|
|
ddaefca5fb | ||
|
|
ba43b4c5e8 | ||
|
|
f52017ef9f | ||
|
|
af9b443865 | ||
|
|
4739849549 | ||
|
|
63a57aa2cf | ||
|
|
ead321cd62 | ||
|
|
516e59f855 | ||
|
|
4ddbb5aede | ||
|
|
d924ccaaa9 | ||
|
|
c324bfcfee | ||
|
|
817cf8c40e | ||
|
|
4e8a56f127 | ||
|
|
6aed5789a5 | ||
|
|
d737347d0b | ||
|
|
5041ec9dc6 | ||
|
|
c2c792f10c | ||
|
|
85b34abc7e | ||
|
|
2bfd2cc06d | ||
|
|
5dbdadbb27 | ||
|
|
1e2a43db50 | ||
|
|
ba7409b7a8 | ||
|
|
f9decee80b | ||
|
|
bef134871d | ||
|
|
46e27c7456 | ||
|
|
9146c433b3 | ||
|
|
00e421861f | ||
|
|
cdd76f7d0c | ||
|
|
a389d2752d | ||
|
|
039da53bbe | ||
|
|
622d64dd45 | ||
|
|
90e661f77b | ||
|
|
88ee8f2cc3 | ||
|
|
bb4a11b20c | ||
|
|
031d7a8ceb | ||
|
|
48dbac7907 | ||
|
|
ea33434bea | ||
|
|
923de901c4 | ||
|
|
fb69cb4e10 | ||
|
|
818e4e95e4 | ||
|
|
2aa1c84e73 | ||
|
|
1b52349f03 | ||
|
|
a9bc2cc127 | ||
|
|
7c08d21142 | ||
|
|
b059cca7b2 | ||
|
|
9e108e397b | ||
|
|
dc972173a4 | ||
|
|
56d07f14b4 | ||
|
|
b47bb62831 | ||
|
|
ece0ec7c41 | ||
|
|
004d232a84 | ||
|
|
0a730e4eef | ||
|
|
5091f8f24f | ||
|
|
87060aa3ef | ||
|
|
670a5b2e87 | ||
|
|
f540a0166d | ||
|
|
b98f4e4c07 | ||
|
|
31eb638a6e | ||
|
|
583605ef3a | ||
|
|
8c6b86ad17 | ||
|
|
8670d2b3bb | ||
|
|
07ec58e41c | ||
|
|
025553f42a | ||
|
|
bfa839f2f9 | ||
|
|
b57e7adb37 | ||
|
|
b1a9a6102c | ||
|
|
c5a7e77f04 | ||
|
|
ed142ab27b | ||
|
|
f29dcf810f | ||
|
|
48cfcb2c36 | ||
|
|
14eea84a36 | ||
|
|
c0efce69f4 | ||
|
|
37b1bead28 | ||
|
|
9f1008336d | ||
|
|
59590639ee | ||
|
|
885f1c5b27 | ||
|
|
ac8e9d1cfa | ||
|
|
fd543ed594 | ||
|
|
c0ec382510 | ||
|
|
e84ca12647 | ||
|
|
c912319967 | ||
|
|
4c421b4cb3 | ||
|
|
2d05149cea | ||
|
|
ad66191d88 | ||
|
|
5e8df7ae01 | ||
|
|
9bac360498 | ||
|
|
7c1d83ccd3 | ||
|
|
4ab3096fa5 | ||
|
|
64215d3ef1 | ||
|
|
075e21fb89 | ||
|
|
9dc0b43737 | ||
|
|
5489de2432 | ||
|
|
d6c95cea4a | ||
|
|
2cb82fdcdc | ||
|
|
2b495f7cda | ||
|
|
9509bbc355 | ||
|
|
c3d9130731 | ||
|
|
4f9b275211 | ||
|
|
db16413754 | ||
|
|
5c17f5ea3c | ||
|
|
ca2a40341c | ||
|
|
c06d6d6deb | ||
|
|
f797f4d897 | ||
|
|
640d6b58c5 | ||
|
|
79a5dac7f1 | ||
|
|
cfda174dc5 | ||
|
|
ee03b9999e | ||
|
|
253bd04588 | ||
|
|
2a542302bc | ||
|
|
e91774fe3b | ||
|
|
23f9023f8b | ||
|
|
5b0d1f92c4 | ||
|
|
ec4e97a7d6 | ||
|
|
19cd66475c | ||
|
|
c353f81b9f | ||
|
|
6e4e1cfed1 | ||
|
|
78966d351f | ||
|
|
eb231e4056 | ||
|
|
ba3b40792b | ||
|
|
7216158053 | ||
|
|
5f36f732b1 | ||
|
|
30aefb47f9 | ||
|
|
1531846482 | ||
|
|
a18e0d9b68 | ||
|
|
557af91d4b | ||
|
|
2bdb804f24 | ||
|
|
8eaec839d3 | ||
|
|
3977db87a3 | ||
|
|
e6abd08963 | ||
|
|
68e922878b | ||
|
|
e9e3bc25af | ||
|
|
d4bb5d8f73 | ||
|
|
7bb7ac3f00 | ||
|
|
42278ad51b | ||
|
|
107c153e98 | ||
|
|
d45fbee647 | ||
|
|
2ac73a06c9 | ||
|
|
91a9397c58 | ||
|
|
af088730e8 | ||
|
|
4b9c7de031 | ||
|
|
712a1ff319 | ||
|
|
21c1a4d11c | ||
|
|
d069233407 | ||
|
|
4a4ce11bcc | ||
|
|
9c650a4c26 | ||
|
|
b96bcb27ad | ||
|
|
b0e12b2027 | ||
|
|
3b68693177 | ||
|
|
fa36b63fbe | ||
|
|
68db16419d | ||
|
|
19cd10ab7b | ||
|
|
bc0055781e | ||
|
|
8c634c8115 | ||
|
|
27c5794700 | ||
|
|
ceb9416fab | ||
|
|
c6733814ba | ||
|
|
1b94ae75ca | ||
|
|
3e7092dbd6 | ||
|
|
61f627b125 | ||
|
|
a578d4f910 | ||
|
|
1bca61da40 | ||
|
|
660a7ce4e8 | ||
|
|
ac31ca8659 | ||
|
|
9200336b49 | ||
|
|
c243e29002 | ||
|
|
175db7b8bc | ||
|
|
581e75ee57 | ||
|
|
b625abae68 | ||
|
|
b0bfbb789c | ||
|
|
2ce277c7e5 | ||
|
|
7ef77267d3 | ||
|
|
d34ae3ef7a | ||
|
|
bfa7ed1cf9 | ||
|
|
aa5088cc97 | ||
|
|
0d0465f722 | ||
|
|
360279f765 | ||
|
|
6c73a8abc0 | ||
|
|
55a0dcb443 | ||
|
|
5b3c52bd9b | ||
|
|
a911d953cd | ||
|
|
c52b5a53cd | ||
|
|
c9a43eeeb1 | ||
|
|
69624d7a5d | ||
|
|
4681a743b4 | ||
|
|
e0b3ec74de | ||
|
|
d0b179108a | ||
|
|
10ac3c720e | ||
|
|
7a3b6b1cfb | ||
|
|
dc1d3f6ce5 | ||
|
|
5cfe6823cc | ||
|
|
6cd348433a | ||
|
|
617bb56f5f | ||
|
|
a859b4ff3e | ||
|
|
46fb8407a6 | ||
|
|
c7d18ad95e | ||
|
|
8f31e4f2cd | ||
|
|
d4446186cd | ||
|
|
8fb8af036d | ||
|
|
68492cd932 | ||
|
|
3c1afb92e1 | ||
|
|
abaacf7ee7 | ||
|
|
a22b898c0c | ||
|
|
d64a4ae81d | ||
|
|
ebd81f7cb4 | ||
|
|
f657f633b4 | ||
|
|
fb6411e923 | ||
|
|
a7e646c28b | ||
|
|
98a226f3ff | ||
|
|
49fb5a6a61 | ||
|
|
fe69841622 | ||
|
|
bb968706ad | ||
|
|
849e669147 |
No files matched your search
@@ -1,3 +1,4 @@
|
||||
*/node_modules
|
||||
**/node_modules
|
||||
.git
|
||||
*/dist
|
||||
**/dist
|
||||
**/tsconfig.tsbuildinfo
|
||||
@@ -0,0 +1,4 @@
|
||||
TESTING=true
|
||||
TESTING_USERNAME=alex
|
||||
TESTING_PASSWORD=Alex1234
|
||||
SECRET_KEY=b1d2e917-17b9-4fce-9055-082dee6ec5d6
|
||||
@@ -2,4 +2,4 @@ vite.config.js
|
||||
.gitignore
|
||||
data
|
||||
mongo-init.js
|
||||
app/dist
|
||||
dist
|
||||
@@ -4,22 +4,33 @@ module.exports = {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
plugins: ['cypress'],
|
||||
plugins: ['@typescript-eslint', 'cypress', 'vue'],
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'airbnb-base',
|
||||
'plugin:prettier/recommended', // We added this
|
||||
'plugin:cypress/recommended',
|
||||
],
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
// parser: 'babel-eslint',
|
||||
ecmaVersion: 13,
|
||||
parser: '@typescript-eslint/parser',
|
||||
sourceType: 'module',
|
||||
},
|
||||
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
vue: {
|
||||
version: '3.0', // Explicitly specify Vue 3
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'warn',
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'import/extensions': 'off',
|
||||
camelcase: ['error', { properties: 'never', ignoreDestructuring: true }],
|
||||
'no-param-reassign': ['error', { props: false }],
|
||||
'no-underscore-dangle': ['error', { allow: ['_id'] }],
|
||||
@@ -29,5 +40,46 @@ module.exports = {
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto', semi: false }],
|
||||
'import/prefer-default-export': 'off',
|
||||
'no-restricted-syntax': ['off', 'ForOfStatement'],
|
||||
'lines-between-class-members': [
|
||||
'error',
|
||||
'always',
|
||||
{ exceptAfterSingleLine: true },
|
||||
],
|
||||
'no-use-before-define': '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
|
||||
'import/extensions': [
|
||||
'warn',
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'off',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
|
||||
rules: {
|
||||
'import/extensions': 'off', // Turn off the rule for test files
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -27,28 +27,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build Test Environment, Stage 1
|
||||
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
|
||||
|
||||
- name: Build Test Quests
|
||||
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
|
||||
|
||||
- name: Run Cypress Tests
|
||||
env:
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
|
||||
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
|
||||
run: docker-compose -f docker-compose.testing.yml run cypress
|
||||
@@ -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
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
# needs: ci-checks
|
||||
needs: ci-checks
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
||||
|
||||
|
||||
@@ -12,23 +12,6 @@ jobs:
|
||||
uses: ./.github/workflows/ci-checks.yml
|
||||
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-4'
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
build:
|
||||
needs: ci-checks
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
# needs: ci-checks
|
||||
needs: ci-checks
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ web_modules/
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# TypeScript build
|
||||
built
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
@@ -132,3 +135,6 @@ dist
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
#screenshots
|
||||
tests/cypress/screenshots
|
||||
@@ -1,2 +1,2 @@
|
||||
data/*
|
||||
app/dist
|
||||
dist
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
@@ -17,14 +17,21 @@
|
||||
|
||||
## Development
|
||||
|
||||
Run everything (frontend, backend, db, swagger, quests):
|
||||
Run mongoDB first:
|
||||
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
If you want to enable social login, you should have OAuth client token in a `.env` file:
|
||||
The db will automatically restarts by Docker.
|
||||
|
||||
docker compose --env-file .env up -d
|
||||
Build up dependencies:
|
||||
|
||||
yarn build:deps
|
||||
|
||||
Run dev server (frontend, backend):
|
||||
|
||||
yarn dev
|
||||
|
||||
If you want to enable social login, you should have OAuth client token in a `.env` file:
|
||||
|
||||
### Build Quest Images
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Base stage
|
||||
FROM node:20-alpine as base
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
COPY package.json yarn.lock tsconfig.json ./
|
||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
COPY packages/constants/package.json ./packages/constants/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
COPY app/package.json ./app/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
@@ -18,8 +20,9 @@ FROM base as build
|
||||
COPY packages/file-graph ./packages/file-graph
|
||||
COPY packages/utils ./packages/utils
|
||||
COPY packages/constants ./packages/constants
|
||||
COPY packages/models ./packages/models
|
||||
COPY app ./app
|
||||
RUN yarn build
|
||||
RUN yarn workspace app build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:stable-alpine as prod
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon_totem.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linux Odyssey</title>
|
||||
</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,15 +2,15 @@
|
||||
"name": "app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "API_TARGET=http://homesrv.dala-banana.ts.net:3000 vite",
|
||||
"dev:local": "vite",
|
||||
"dev": "vite",
|
||||
"dev:docker": "API_TARGET=http://backend:3000 vite",
|
||||
"build": "vite build",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"type-check": "vue-tsc -b",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/js": "^2.1.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||
@@ -18,9 +18,13 @@
|
||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||
"@linux-odyssey/constants": "*",
|
||||
"@linux-odyssey/file-graph": "*",
|
||||
"@linux-odyssey/models": "*",
|
||||
"@linux-odyssey/utils": "*",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"axios": "^1.6.0",
|
||||
"echarts": "^5.4.3",
|
||||
"dompurify": "^3.0.6",
|
||||
"marked": "^10.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.7.1",
|
||||
"validator": "^13.11.0",
|
||||
"vue": "^3.3.4",
|
||||
@@ -32,10 +36,14 @@
|
||||
"xterm-addon-fit": "^0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@types/validator": "^13.11.9",
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"@vue/compiler-sfc": "^3.3.13",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"vite": "^4.4.0"
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^4.5.3",
|
||||
"vue-tsc": "^2.0.10"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export default {
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -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,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 truncated
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 630 KiB |
@@ -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
|
||||
}
|
||||
|
||||
@@ -83,13 +83,28 @@ const handleChange = () => {
|
||||
errorMessage.value = 'Something went wrong'
|
||||
}
|
||||
}
|
||||
|
||||
const registerGuest = async () => {
|
||||
try {
|
||||
await api.post('/auth/register-guest')
|
||||
router.push({ name: 'map' })
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.error('Something went wrong.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<!-- login page -->
|
||||
<div
|
||||
class="bg-bg border-8 border-bg-primary flex flex-1 flex-col items-center justify-center rounded-3xl p-10"
|
||||
>
|
||||
<h1 class="text-text-primary text-3xl font-black mb-2">{{ title }}</h1>
|
||||
<div class="w-full flex items-center justify-center mb-2 gap-3">
|
||||
<img src="../img/icon_totem.svg" class="h-10" />
|
||||
<h1 class="text-text-primary text-3xl font-black" id="title">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="text-error inline-flex justify-center items-center md:hidden">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'circle-exclamation']"
|
||||
@@ -109,10 +124,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"
|
||||
@@ -124,32 +136,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"
|
||||
@@ -163,6 +160,7 @@ const handleChange = () => {
|
||||
/>
|
||||
<label
|
||||
class="text-sm font-normal text-text-secondary"
|
||||
id="NameRules"
|
||||
v-if="type === 'username' || type === 'register'"
|
||||
>
|
||||
<p>可用小寫字母、數字、"_"和"-"</p>
|
||||
@@ -170,17 +168,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"
|
||||
@@ -194,15 +181,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"
|
||||
@@ -225,46 +203,39 @@ 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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icons: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
desciption: String,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<a :href="url" target="_blank" class="h-10 w-10" :title="desciption">
|
||||
<font-awesome-icon
|
||||
:icon="icons"
|
||||
:alt="title"
|
||||
class="text-text-primary h-10 delay-130 hover:-translate-y-1 hover:scale-110"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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'
|
||||
|
||||
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,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object,
|
||||
@@ -20,7 +20,21 @@ function nodeClass() {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
|
||||
<FileNode :node="child" :pwd="pwd" />
|
||||
|
||||
@@ -1,160 +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)
|
||||
}
|
||||
}
|
||||
const handleMap = async () => {
|
||||
try {
|
||||
router.push({ name: 'map' })
|
||||
} catch (err) {
|
||||
// toast.error('Failed to load map')
|
||||
toast.error('無法讀取地圖')
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
const handleLeaderBoard = async () => {
|
||||
try {
|
||||
router.push({ name: 'leaderboard' })
|
||||
} catch (err) {
|
||||
// toast.error('Failed to load map')
|
||||
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> -->
|
||||
<button
|
||||
id="HeaderText"
|
||||
class="text-text-primary inline-block font-bold whitespace-nowrap"
|
||||
style="font-size: 2vh"
|
||||
>
|
||||
Linux Odyssey
|
||||
</button>
|
||||
<p
|
||||
id="HeaderText"
|
||||
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>
|
||||
<button title="LeaderBoard" @click="handleLeaderBoard" class="h-5 w-5">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'trophy']"
|
||||
class="text-text-primary h-full w-full"
|
||||
/>
|
||||
</button>
|
||||
<button title="Map" @click="handleMap" class="h-5 w-5">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'map']"
|
||||
class="text-text-primary h-full w-full"
|
||||
/>
|
||||
</button>
|
||||
<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,77 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import sessionStore from '../store/session'
|
||||
|
||||
const current = ref(-1)
|
||||
watch(
|
||||
() => sessionStore.session.hints,
|
||||
(hints) => {
|
||||
current.value = hints.length - 1
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const left = () => {
|
||||
if (current.value > 0) {
|
||||
current.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
const right = () => {
|
||||
if (current.value < sessionStore.session.hints.length - 1) {
|
||||
current.value += 1
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full flex items-center p-2">
|
||||
<font-awesome-icon
|
||||
:icon="['far', 'lightbulb']"
|
||||
class="text-yellow-200 p-2 content-center"
|
||||
/>
|
||||
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">
|
||||
<!-- Hint -->
|
||||
提示
|
||||
</h1>
|
||||
<div v-if="current !== -1" class="flex w-full justify-end items-end">
|
||||
<button
|
||||
class="p-2 m-1 w-1/8"
|
||||
id="Lbutton"
|
||||
@click="left"
|
||||
:disabled="current <= 0"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'arrow-right']"
|
||||
class="text-text"
|
||||
rotation="180"
|
||||
/>
|
||||
</button>
|
||||
<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">
|
||||
{{ sessionStore.session.hints.length }}
|
||||
</p>
|
||||
<button
|
||||
class="p-2 m-1 w-1/8"
|
||||
id="Rbutton"
|
||||
@click="right"
|
||||
:disabled="current === sessionStore.session.hints.length - 1"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hint" class="bg-bg flex flex-wrap p-8">
|
||||
<ul>
|
||||
<li
|
||||
v-for="hint in sessionStore.session.hints[current]"
|
||||
:key="hint"
|
||||
class="text-text font-xl whitespace-pre-wrap"
|
||||
>
|
||||
{{ hint }}
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps } from 'vue'
|
||||
import markdown2HTML from '../utils/markdown'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const html = computed(() => {
|
||||
return markdown2HTML(props.content)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-content" v-html="html"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-content {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.markdown-content :deep(p) {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.markdown-content :deep(ol) {
|
||||
list-style: decimal;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.markdown-content :deep(code) {
|
||||
background-color: #282a33;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,191 +1,254 @@
|
||||
<!-- eslint-disable no-console -->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
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 api from '../utils/api'
|
||||
import { NodeImage } from '../img/svg.js'
|
||||
import { getQuests } from '../utils/api'
|
||||
import useUserProfile from '../store/userProfile'
|
||||
import QuestIntro from './QuestIntro.vue'
|
||||
|
||||
const marginX = 500
|
||||
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: ['150%', '50%'],
|
||||
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 store = useUserProfile()
|
||||
const toast = useToast()
|
||||
|
||||
function initChart(option) {
|
||||
chartInstance = init(chartContainer.value)
|
||||
chartInstance.setOption(option)
|
||||
chartInstance.on('click', (params) => {
|
||||
const {
|
||||
data: { id, unlocked },
|
||||
} = params
|
||||
const svgWidth = ref(window.innerWidth)
|
||||
const svgHeight = ref(window.innerHeight)
|
||||
|
||||
if (unlocked) {
|
||||
router.push({ name: 'game', params: { questId: id } })
|
||||
} else {
|
||||
// toast.warning('You have not completed the previous quest yet!')
|
||||
toast.warning('你還沒完成前一個關卡!')
|
||||
}
|
||||
})
|
||||
const marginX = 120
|
||||
const marginY = 120
|
||||
|
||||
const nodeWidth = 160
|
||||
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 () => {
|
||||
const quests = await getQuests()
|
||||
const progress = await getProgress()
|
||||
const option = getOption(quests, progress)
|
||||
initChart(option)
|
||||
await store.loadUserProfile()
|
||||
await computeGraphData()
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="relative bg-black w-full h-full flex">
|
||||
<img
|
||||
src="../img/catelogbg.svg"
|
||||
alt="bg"
|
||||
class="p-2 stroke-2 scale-y-120 justify-center items-center"
|
||||
/>
|
||||
<h1
|
||||
class="p-4 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
|
||||
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
|
||||
<div
|
||||
class="flex place-content-center w-full bg-catelogbg bg-cover bg-scroll"
|
||||
>
|
||||
<!-- Get through your linux journey! -->
|
||||
踏上你的Linux冒險之旅吧!
|
||||
</h1>
|
||||
<div class="flex flex-wrap absolute w-full h-full z-1">
|
||||
<div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
|
||||
<h1
|
||||
class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
|
||||
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
|
||||
>
|
||||
踏上你的Linux冒險之旅吧!
|
||||
</h1>
|
||||
<svg
|
||||
:width="svgWidth"
|
||||
:height="svgHeight"
|
||||
class="absolute"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="endDrag"
|
||||
@mouseleave="endDrag"
|
||||
>
|
||||
<g :transform="`translate(${offsetX}, ${offsetY})`">
|
||||
<g
|
||||
v-for="edge in graphData.edges"
|
||||
:key="`${edge.source.id}-${edge.target.id}`"
|
||||
>
|
||||
<path
|
||||
:d="curvedPath(edge.source, edge.target)"
|
||||
:class="['edge', edgeStyle(edge)]"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
v-for="node in graphData.nodes"
|
||||
:key="node.id"
|
||||
@click="handleNodeClick(node)"
|
||||
>
|
||||
<rect
|
||||
:x="node.x - nodeWidth / 2"
|
||||
:y="node.y - nodeHeight / 2"
|
||||
:width="nodeWidth"
|
||||
:height="nodeHeight"
|
||||
rx="10"
|
||||
ry="10"
|
||||
:class="['node', nodeStyle(node)]"
|
||||
/>
|
||||
<text
|
||||
:x="node.x"
|
||||
:y="node.y"
|
||||
text-anchor="middle"
|
||||
alignment-baseline="middle"
|
||||
:class="['node-text', nodeStyle(node)]"
|
||||
font-size="18"
|
||||
>
|
||||
{{ node.title }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<QuestIntro
|
||||
:questTitle="opened.title"
|
||||
:questId="opened.id"
|
||||
:questUnlocked="opened.unlocked"
|
||||
v-if="opened"
|
||||
@close-intro="closeIntro"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.node {
|
||||
&.completed {
|
||||
fill: #6cf76c;
|
||||
}
|
||||
&.unlocked {
|
||||
fill: #8c8c92;
|
||||
}
|
||||
&.locked {
|
||||
fill: #505050;
|
||||
}
|
||||
}
|
||||
|
||||
.node-text {
|
||||
&.completed {
|
||||
fill: #1d1d1d;
|
||||
}
|
||||
&.unlocked {
|
||||
fill: #ffffff;
|
||||
}
|
||||
&.locked {
|
||||
fill: #a0a0a0;
|
||||
}
|
||||
}
|
||||
|
||||
.edge {
|
||||
stroke-width: 3;
|
||||
fill: none;
|
||||
&.unlocked {
|
||||
stroke: #adadb5;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
&.locked {
|
||||
stroke: #454552;
|
||||
stroke-dasharray: 5, 5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup>
|
||||
import sessionStore from '../store/session'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full max-h-full">
|
||||
<p id="topic" class="text-text font-black" v-if="sessionStore.quest">
|
||||
{{ sessionStore.quest.title }}
|
||||
</p>
|
||||
<br />
|
||||
<div>
|
||||
<div v-if="sessionStore.quest" class="whitespace-pre-wrap">
|
||||
<p class="text-text">{{ sessionStore.quest.instruction }}</p>
|
||||
<br />
|
||||
<p id="tasks" class="text-text">
|
||||
<!-- Tasks: -->
|
||||
任務:
|
||||
</p>
|
||||
<ul v-if="sessionStore.session">
|
||||
<li v-for="task in sessionStore.session.tasks" :key="task.id">
|
||||
<p v-if="task.completed" class="text-text-primary">
|
||||
<span class=""> ✓</span>
|
||||
{{ task.name }}
|
||||
</p>
|
||||
<p v-else class="text-text">
|
||||
<span class="">➤</span>
|
||||
{{ task.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="text-text" v-else>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,17 +1,19 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { ref } from 'vue'
|
||||
import { Vue3Lottie } from 'vue3-lottie'
|
||||
// import { surveyUrl } from '../config'
|
||||
import CompleteAnimation from '../img/complete-animation.json'
|
||||
import CompleteAnimation from '../../img/complete-animation.json'
|
||||
|
||||
const showModal = ref(true) // Controls whether the modal is visible or not
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
const defineComponent = () => ({
|
||||
components: {
|
||||
Vue3Lottie,
|
||||
@@ -22,11 +24,11 @@ const defineComponent = () => ({
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const backtoMap = async () => {
|
||||
try {
|
||||
router.push({ name: 'map' })
|
||||
} catch (err) {
|
||||
// useToast().error('Failed to load map')
|
||||
toast.error('無法讀取地圖')
|
||||
console.error(err)
|
||||
}
|
||||
@@ -47,7 +49,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">
|
||||
@@ -61,17 +62,6 @@ setTimeout(() => {
|
||||
<p class="text-base text-text text-center">
|
||||
接下來回到地圖,迎接新的挑戰吧!
|
||||
</p>
|
||||
<!-- <p class="text-base text-text">
|
||||
您已成功完成挑戰,真的非常感謝您的參與!<br />
|
||||
如果您能撥冗填寫這份問卷,我們將不勝感激,這對我們非常重要!
|
||||
</p> -->
|
||||
<!-- <a
|
||||
id="SurveyButton"
|
||||
:href="surveyUrl"
|
||||
target="_blank"
|
||||
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
|
||||
>填寫問卷</a
|
||||
> -->
|
||||
<a
|
||||
id="BacktoMap"
|
||||
target="_blank"
|
||||
@@ -80,6 +70,21 @@ setTimeout(() => {
|
||||
>回到地圖</a
|
||||
>
|
||||
</div>
|
||||
<div style="position: relative; height: 100vh; overflow: auto">
|
||||
<iframe
|
||||
src="https://app.formbricks.com/s/clyffdm0c0be2wh6335clle0l"
|
||||
frameborder="0"
|
||||
style="
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import useSession from '../../store/session'
|
||||
import MarkdownText from '../MarkdownText.vue'
|
||||
|
||||
const sessionStore = useSession()
|
||||
const current = ref(-1)
|
||||
const length = computed(() => {
|
||||
return sessionStore.session?.responses.length ?? 0
|
||||
})
|
||||
|
||||
const disabled = computed(() => {
|
||||
return current.value === length.value - 1
|
||||
})
|
||||
|
||||
watch(length, () => {
|
||||
current.value = length.value - 1
|
||||
})
|
||||
|
||||
const left = () => {
|
||||
if (current.value > 0) {
|
||||
current.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
const right = () => {
|
||||
if (current.value < length.value - 1) {
|
||||
current.value += 1
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex items-center p-2 h-10">
|
||||
<font-awesome-icon
|
||||
:icon="['far', 'lightbulb']"
|
||||
class="text-yellow-200 p-2 content-center"
|
||||
/>
|
||||
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">說明</h1>
|
||||
<div v-if="current !== -1" class="flex w-full justify-end items-end">
|
||||
<button
|
||||
class="p-2 m-1 w-1/8"
|
||||
id="Lbutton"
|
||||
@click="left"
|
||||
:disabled="current <= 0"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'arrow-right']"
|
||||
class="text-text"
|
||||
rotation="180"
|
||||
/>
|
||||
</button>
|
||||
<p class="inline text-text font-xl w-1/14 p-2 m-1">{{ current + 1 }}</p>
|
||||
<p class="inline text-text font-xl w-1/14 p-2 m-1">/</p>
|
||||
<p class="inline text-text font-xl w-1/14 p-2 m-1">
|
||||
{{ length }}
|
||||
</p>
|
||||
<button
|
||||
class="p-2 m-1 w-1/8"
|
||||
id="Rbutton"
|
||||
@click="right"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hint" class="bg-bg p-8 overflow-y-auto">
|
||||
<ul>
|
||||
<li
|
||||
v-for="response in sessionStore.session?.responses[current]"
|
||||
:key="response.type"
|
||||
class="text-text font-xl whitespace-pre-wrap"
|
||||
>
|
||||
<div v-for="content in response.content" :key="content">
|
||||
<MarkdownText :content="content" />
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
v-for="hint in sessionStore.session?.hints[current]"
|
||||
:key="hint"
|
||||
class="text-text-primary font-xl whitespace-pre-wrap"
|
||||
>
|
||||
<MarkdownText :content="hint" />
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import MarkdownText from '../MarkdownText.vue'
|
||||
import useSession from '../../store/session'
|
||||
import { Task } from '../../types'
|
||||
|
||||
const sessionStore = useSession()
|
||||
const tasks = computed(() => {
|
||||
return sessionStore.session?.tasks.map((task: Task) => {
|
||||
const prefix = task.completed ? '✓' : '➤'
|
||||
const color = task.completed ? 'text-text-primary' : 'text-text'
|
||||
return {
|
||||
...task,
|
||||
class: `${color} markdown-content`,
|
||||
content: `${prefix} ${task.name}`,
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full max-h-full">
|
||||
<p id="topic" class="text-text font-black" v-if="sessionStore.quest">
|
||||
{{ sessionStore.quest.title }}
|
||||
</p>
|
||||
<br />
|
||||
<div>
|
||||
<div v-if="sessionStore.quest" class="whitespace-pre-wrap">
|
||||
<MarkdownText
|
||||
class="text-text markdown-content"
|
||||
:content="sessionStore.quest.instruction"
|
||||
/>
|
||||
<br />
|
||||
<p id="tasks" class="text-text">任務:</p>
|
||||
<ul v-if="sessionStore.session">
|
||||
<li v-for="task in tasks" :key="task.id">
|
||||
<MarkdownText :class="task.class" :content="task.content" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="text-text" v-else>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
<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>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import FileNode from './FileNode.vue'
|
||||
import sessionStore from '../store/session.js'
|
||||
<script setup lang="ts">
|
||||
import FileNode from '../FileNode.vue'
|
||||
import useSession from '../../store/session'
|
||||
|
||||
const sessionStore = useSession()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -8,6 +10,7 @@ import sessionStore from '../store/session.js'
|
||||
<ul>
|
||||
<li>
|
||||
<FileNode
|
||||
v-if="sessionStore.session"
|
||||
:node="sessionStore.session.graph"
|
||||
:pwd="sessionStore.session.pwd"
|
||||
/>
|
||||
@@ -45,14 +48,14 @@ import sessionStore from '../store/session.js'
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 50%;
|
||||
border-top: 1px solid #00ff00;
|
||||
border-top: 2px solid #00ff00;
|
||||
width: 50%;
|
||||
height: 20px;
|
||||
}
|
||||
.tree li::after {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
border-left: 1px solid #00ff00;
|
||||
border-left: 2px solid #00ff00;
|
||||
}
|
||||
|
||||
.tree li:only-child::after,
|
||||
@@ -70,7 +73,7 @@ import sessionStore from '../store/session.js'
|
||||
}
|
||||
|
||||
.tree li:last-child::before {
|
||||
border-right: 1px solid #00ff00;
|
||||
border-right: 2px solid #00ff00;
|
||||
border-radius: 0 5px 0 0;
|
||||
-webkit-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;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
border-left: 1px solid #00ff00;
|
||||
border-left: 2px solid #00ff00;
|
||||
width: 0;
|
||||
height: 20px;
|
||||
}
|
||||
@@ -97,7 +100,7 @@ import sessionStore from '../store/session.js'
|
||||
text-decoration: none;
|
||||
color: #ffffff;
|
||||
font-family: arial, verdana, tahoma;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
display: inline-block;
|
||||
|
||||
border-radius: 5px;
|
||||
@@ -109,18 +112,10 @@ import sessionStore from '../store/session.js'
|
||||
-moz-transition: all 0.5s;
|
||||
}
|
||||
|
||||
a.discovered {
|
||||
background: #526b4e;
|
||||
color: #000;
|
||||
border: 1px solid #94a0b4;
|
||||
}
|
||||
|
||||
ul.discovered,
|
||||
li.discovered {
|
||||
border-color: #94a0b4;
|
||||
}
|
||||
|
||||
a.pwd {
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
|
||||
.tree li a.pwd {
|
||||
border: 3px solid #00ff00;
|
||||
background: #008080;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -1,7 +0,0 @@
|
||||
export const surveyUrl = '/api/v1/survey'
|
||||
export const bugReportUrl = '/api/v1/bug-report'
|
||||
|
||||
export default {
|
||||
surveyUrl,
|
||||
bugReportUrl,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const surveyUrl = '/api/v1/survey'
|
||||
export const bugReportUrl = '/api/v1/bug-report'
|
||||
export const facebookUrl = 'https://www.facebook.com/LinuxOdyssey'
|
||||
export const githubUrl = 'https://github.com/linux-odyssey/linux-odyssey'
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M0 0H512V512H0V0Z" fill="url(#pattern0)"/>
|
||||
<defs>
|
||||
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_426_5" transform="scale(0.00195312)"/>
|
||||
</pattern>
|
||||
<image id="image0_426_5" width="512" height="512" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAN1wAADdcBQiibeAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAACAASURBVHic7N13nCV1nf3/V9UNHWcGScrqgEoWBhB13TWjCCimNdHdpnuVbhX1ortr3J+r7vpz17TqNa3gesvADMGIoChmwRzAgHFEyTTDpO6Znr73VtX3j9uX6em53X1DVX2q6p7nY/sBdKh67zjd5/Sn6n7K8n0fiadioTQIHLjwdtCif1/tfVlg58LbjiX/bPW+xR+7ueKU747k/0ERETHGUgEwr1go5YATgYcuejsOGDY00s3AdUvebqw4Zf1lERFJCRWAiBULpQzwIPYN+5OBAZNztWEHcD37loLfVpxy1ehUIiLSFRWAEBULJRs4BngYe8P+FMz9Zh+0GnADewvBVRWn/HuzI4mISDtUAEJQLJSOAM4HCsC9zE4TuRuAzwKfrTjlX5seRkREWlMBCFixUHo68ClgrelZYuCP7C0DvzQ9jIiI7KUCEKBioXQs8GsgZ3qWGPoL8DkaZeAnpocREel3KgABKhZKVwBnm54jAW5ioQwAP9SrC0REoqcCEJBioXQG8DXTcyTQbcBFwIcrTvlvpocREekXtukBUkS/+Xfn74DXApuLhdKlxULpEaYHEhHpB1nTA6SIylRvMsBzgOcUC6WfAO8HLqs45brZsURE0kmhFRz9WQbn74GNwI3FQuk1C1sii4hIgBRawdlleoAUuh/wP8Cfi4XSVLFQ0oqViEhAdBNgQIqF0sk0dsOT8PwZeAtwccUpe6aHERFJMq0ABKTilK+n8bI2Cc9RNF4xcN3ChksiItIlrQAEqFgoHQD8HHig6Vn6xLeBV1Sc8u9MDyIikjQqAAErFkr3B74DHGF2kr5RA94L/GfFKe82PYyISFKoAIRAJcCIm4BSxSl/yfQgIiJJoAIQEpUAY64EXlVxyjeaHkREJM50E2BIKk75r8DjAG1vG62zgd8UC6Up04OIiMSZVgBCppUAo74EnFtxyltMDyIiEjcqABFQCTDqduBFFad8telBRETiRAUgIioBRvk0ni3wxopTnjc9jIhIHKgAREglwLhfAc+pOOU/mh5ERMQ03QQYId0YaNxJwE+KhdKTTA8iImKaCkDEVAKMWwdcUSyUXmd6EBERk3QJwBBdDoiFTcBLKk55zvQgIpJM01tm1gNnATPAFuDGQw9es9nsVO1RATBIJcA8z3VvqNfrL7xo08d+bnoWEUmO6S0zZwDn0wj/xavpPvBx4I2HHrzmbhOztUsFwDCVAPP2zM3N2Rn7RRdt/NhlpmcRkfib3jLzbOASVr6MfjfwykMPXnNxNFN1TvcAGKZ7AszLD+SHqvPzlzxv/KUvNz2LiMTb9JaZRwOfYfX8PAjYOL1l5sXhT9UdFYAYUAkwy7Yz5HI5q1qd/8jE+NSbTc8jIvE0vWXmXsAXgYE2v8QCPh7XEqACEBMqAWblBwYBqM7P/8fE2NQHDY8jIvH0euDADr8mtiVA9wDEjO4JMKdWq7JnrvGCgFw+f+mmiy88x/BIIhIT01tmDgM2A0NdHsIHzj304DWfCG6q3mgFIGa0EmBOLpcnk8kAUKtWnzt2zrnfNDySiMTHm+k+/CGGKwFaAYgprQSY4bouu3fN3vPf2Vzu2xdf8vHHGxxJRAyb3jJzJPA7IBfA4WKzEqAVgJjSSoAZmUyGXC5/z3/Xa7XTxs85Vy8PFOlvbyOY8IcYrQSoAMSYSoAZA4ODWJZ1z3/XarVnj58z+VGDI4mIIdNbZjYA4wEfNhYlQAUg5lQComdZFvmBfV/lU6tVXzY+NvmfhkYSEXP+f8LJSuMlQPcAJITuCYjertkZPM/b5325fP78TRdfWDY0kohEaHrLzCOAa0M+jbF7ArQCkBBaCYjewODgfu+r12rvnxibfJ6BcUQkeu+I4BzGVgJUABJEJSBa2WwO2973W8T3fatWq31qYnzqsYbGEpEITG+ZOQuI6vvcSAnQJYAE0uWA6FSrVeb37P+0YNu29+Ry+cMv2vSxuwyMJSIhmt4yYwE/A06N+NSRXg7QCkACaSUgOrlcbp9XBDR5njdYd+s/MzCSiITvOUQf/hDxSoAKQEKpBETDsixyudYv/3Xr9cPHzzn3iohHEpEQTW+ZyQAmX/ETWQlQAUgwlYBo5PLLP/irVqudPT42+boIxxGRcBWBYwzPEEkJ0D0AKaB7AsI3t3sX9Xq95ccsy/JzufxjNl58wTURjyUiAZreMjMI/Am4n+lZFoR6T4BWAFJAKwHhW2kVwPd9q16vfX1ibOqgCEcSkeCdR3zCH0JeCVABSAmVgHBls9n9XhK4mOd5Q57nhr1hiIiEZHrLzBrgjabnaCG0EqACkCIqAeHKr7AKAFCv148dH5t8W0TjiEiw/gU42PQQywilBOgegBTSPQHh8H2fXbMzrPQ9Y1mWl8vnj9u46YI/RTiaiPRgesvMwcBfgDWmZ1lFoPcEaAUghbQSEI7GSwLzK36O7/u2W3e/GdFIIhKMNxH/8IeAVwJUAFJKJSAcufzKBQDAdevrx8+Z1AODRBJgesvMeho3/yVFYCVABSDFVAKCZ9s22Wx21c+r12uvHB+bPCWCkUSkN28BVr7BJ34CKQG6B6AP6J6AYNWqVfa0eD7AUplM5i7Xde/92c9/Ut9kIjE0vWXmWOC3QMb0LF3q6Z4ArQD0Aa0EBKudFQAA13UPyeXyHwl5HBHp3n+S3PCHhZWAO+/aeW5XX6wVgP6hlYDg7No1i+e6q37ewqsC7rtx0wV3RDCWiLRpesvMqTSe+Lf/074SZm5uzp/dtds54bj7d3RJQCsAfUQrAcFpdxXA933bc70vhDyOiHTuHaQg/AEsy7JmZmaLv/39Xzu6FKAC0GdUAoKRzbZ+QmAr9XrtHybGJp8Q4jgi0oHpLTOPBc40PUdQXNcDoNMSoALQh1QCepfJZLCs9n95cF33MyGOIyKd+S/TAwRpbm7PPf/eSQlQAehTKgG9a/cyAIDruvcZP2fyTSGOIyJtmN4y81TgH03PERTP89i1e/c+72u3BOgmwD6nGwO7V6vV2DO3e/VPXGDbdtXOZA64+JKPr/4aQhEJ3PSWGRu4DthgepagTN+1hdnZXS0/tmbNaGWlGwO1AtDntBLQvU5WAAA8z8tb8MmQxhGR1Y2TkvD3fdi6dduy4Q+rrwRoBUAArQR0a/euXbhuve3PtyzLy+cHDrxo08d2hDiWiCwxvWUmB/weeKDpWXpVq9W4a8tW9uzZs/ons/xKgFYABNBKQLc6XQXwfd/2PO/CkMYRkeWdS8LDv16vc9eWu7n5ltvaDn+4ZyXgf5e+XysAsg+tBHTG81x2zc529DWWZbu5XG7dxosvWH7tTkQCM71lZhj4M3CY6Vm64bou23fsZOfOlR9HvhLbsvy169Y+7Lij1//8nvcFNqGkglYCOmPbGWy7s28j3/cyvu99OKSRRGR/ryKB4e95Htu2befmW25jx46dXYc/gOf71vye+S8ufp9WAKQlrQS0b8/cbmq1WkdfY9l2LWPbay6+9P/mQxpLRIDpLTOPAq4CRkzP0i7f99m5c4btO3bcs8lPUNatXTt1/LGHXwhaAZBlaCWgfbbd+bNEfM/LWZb1gRDGEZEFC+H/VRIS/r4PMzOz3HzLrdy9dVvg4Q/g+d49eyCoAMiyVALaY2e6+zaqu27x2c98kb4HRUKwKPxHTc/Sjl27dnPLrbdx15a7qddXf9BYt3zfv3fz3/XDR1akErC6blYAAHzPy+fy+TcHPI5I30tS+O+em+PWW2/nzum7Or6U2A3f9w9s/rsKgKxKJWBlnd4EuJjneucFOIpI30tK+O/ZM89tt9/JHXdMM1+tRnbeTCZzz9NJVQCkLQsl4DRgi+FRYqnbVQDXrR86MT718IDHEelLSQj/arXKHXdOc9vtd3T0Wv4gZLPZWsa239P8bxUAaVvFKd8IPAsIf50qYbq9DwDA87x3BjiKSF+Ke/jXanWm79rCLbfezu7dZh4HMjg4cNWxR6+/585CvQxQOlYslKaAj5meI06q8/PMz3fX5i3LcjPZ7PDFl3w8unVAkRSJc/i7rsu2bTuYmZ3t6XX8vRoZGb52w4Me+KjF79MKgHSs4pQvAL5ieo446eU+AN/3MxbWvwc4jkjfiGv4e57H1q3buOnmW9k50/0Ofr2yLFgzOvrppeEPKgDSvX8DtHy0wM50dw9Ak+e5kwGNItI34hj+vu+zffsObrr5Vrb3uHtfrwYHB29bt3btU044/v4vbPVxXQKQrhULpYuBc0zPERczO3t7wF8unz9108UX/jKgcURSLW7h7/s+O2dm2b59B64b3uv42zEwMLB1cHDgn48/5vAVHz/e2aPMRPb1blQA7mHbNp7Xw85dPm8Fnh7UPCJpFbfwn5mdZdu2HdTr7T8aPAz5fG7X4ODgWx907BHvWf2ztQIgPSoWSrcA9zU9RxzMze2m3sNGHplMZuaSyz6xNsCRRFInTuG/a/dutm3dTjWCDXxWkstl54cGh97/oOOOeEMnX6d7AKRXV5oeIC56uREQwHXdNRPjUycFNI5I6sQl/Ofm9nDrbXdw5513GQ3/bDZTX7Nm9OOjIyOjnYY/dHAJoFgoHQMcCWRoFIdbKk75F52eUFLnKmDK9BBxYFu992nf818HPL/3aUTSJQ7hPz9fZeu2bczNRbuBz1KZjO0NDQ19PpfNFo89ev1st8dZtQAUC6WDgbcBL6UR/os/9mvgvIpTvqbbASTxtD1wk9X7ITzPPav3o4iki+nw9zyfu7ZsYdeu3SZOfw/btv3hoaFv5XLZiWOLine truncated
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M0 0H512V512H0V0Z" fill="url(#pattern0)"/>
|
||||
<defs>
|
||||
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_424_19" transform="scale(0.00195312)"/>
|
||||
</pattern>
|
||||
<image id="image0_424_19" width="512" height="512" xlink:href="data:image/png;base64,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 truncated
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg width="474" height="475" viewBox="0 0 474 475" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M0 0H474V475H0V0Z" fill="url(#pattern0)"/>
|
||||
<defs>
|
||||
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_426_3" transform="matrix(0.00215972 0 0 0.00215517 -0.00105485 0)"/>
|
||||
</pattern>
|
||||
<image id="image0_426_3" width="464" height="464" xlink:href="data:image/png;base64,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 truncated
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -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,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 truncated
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 630 KiB |
@@ -3,49 +3,62 @@ import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
/* import specific icons */
|
||||
import {
|
||||
faBars,
|
||||
faArrowLeft,
|
||||
faList,
|
||||
faGear,
|
||||
faTriangleExclamation,
|
||||
faExpand,
|
||||
faTerminal,
|
||||
faArrowRotateLeft,
|
||||
faArrowRightFromBracket,
|
||||
faArrowRight,
|
||||
faArrowRightFromBracket,
|
||||
faArrowRotateLeft,
|
||||
faBars,
|
||||
faBug,
|
||||
faCircleExclamation,
|
||||
faMap,
|
||||
faTrophy,
|
||||
faEnvelope,
|
||||
faExpand,
|
||||
faFileInvoice,
|
||||
faGear,
|
||||
faList,
|
||||
faMap,
|
||||
faPlay,
|
||||
faTerminal,
|
||||
faTriangleExclamation,
|
||||
faTrophy,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import {
|
||||
faLightbulb,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faFile,
|
||||
faFolder,
|
||||
faLightbulb,
|
||||
} from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGithub, faGoogle } from '@fortawesome/free-brands-svg-icons'
|
||||
import {
|
||||
faFacebook,
|
||||
faGithub,
|
||||
faGoogle,
|
||||
} from '@fortawesome/free-brands-svg-icons'
|
||||
|
||||
/* add icons to the library */
|
||||
library.add(
|
||||
faBars,
|
||||
faArrowLeft,
|
||||
faList,
|
||||
faGear,
|
||||
faTriangleExclamation,
|
||||
faExpand,
|
||||
faTerminal,
|
||||
faArrowRotateLeft,
|
||||
faArrowRightFromBracket,
|
||||
faArrowRight,
|
||||
faArrowRightFromBracket,
|
||||
faArrowRotateLeft,
|
||||
faBars,
|
||||
faBug,
|
||||
faMap,
|
||||
faTrophy,
|
||||
faCircleExclamation,
|
||||
faLightbulb,
|
||||
faEnvelope,
|
||||
faExpand,
|
||||
faFileInvoice,
|
||||
faGear,
|
||||
faList,
|
||||
faMap,
|
||||
faPlay,
|
||||
faTerminal,
|
||||
faTriangleExclamation,
|
||||
faTrophy,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
faFile,
|
||||
faFolder,
|
||||
faLightbulb,
|
||||
faFacebook,
|
||||
faGithub,
|
||||
faGoogle,
|
||||
faFileInvoice
|
||||
faGoogle
|
||||
)
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import './style.css'
|
||||
/* import font awesome icon component */
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
@@ -8,16 +10,24 @@ import Toast, { POSITION } from 'vue-toastification'
|
||||
/* import Vue Lottie */
|
||||
import Vue3Lottie from 'vue3-lottie'
|
||||
import 'vue-toastification/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import formbricks from './utils/formbricks'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.component('font-awesome-icon', FontAwesomeIcon)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
router.afterEach(() => {
|
||||
if (typeof formbricks !== 'undefined') {
|
||||
formbricks.registerRouteChange()
|
||||
}
|
||||
})
|
||||
|
||||
const options = {
|
||||
position: POSITION.TOP_RIGHT,
|
||||
}
|
||||
@@ -5,15 +5,14 @@ import RegisterPage from './views/RegisterPage.vue'
|
||||
import ChooseUsernamePage from './views/ChooseUsernamePage.vue'
|
||||
import QuestMapPage from './views/QuestMapPage.vue'
|
||||
import LeaderboardPage from './views/LeaderboardPage.vue'
|
||||
import LandingPage from './views/LandingPage.vue'
|
||||
import { isLoggedIn } from './utils/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: {
|
||||
name: 'register',
|
||||
},
|
||||
meta: { requiresGuest: true },
|
||||
name: 'landing-page',
|
||||
component: LandingPage,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
@@ -58,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,151 +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: [],
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 updateHints(hints) {
|
||||
store.session.hints.push(hints)
|
||||
}
|
||||
|
||||
function updateStatus(status) {
|
||||
store.session.status = status
|
||||
}
|
||||
|
||||
function setTasks(tasks) {
|
||||
store.session.tasks = tasks
|
||||
}
|
||||
|
||||
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('hints', (event) => {
|
||||
updateHints(event)
|
||||
})
|
||||
socket.on('tasks', (tasks) => {
|
||||
setTasks(tasks)
|
||||
})
|
||||
socket.on('status', (event) => {
|
||||
updateStatus(event)
|
||||
})
|
||||
}
|
||||
|
||||
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,11 +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
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
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,9 +3,11 @@ 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, // 行数
|
||||
cols, // 不指定行数,自动回车后光标从下一行开始
|
||||
convertEol: true, // 启用时,光标将设置为下一行的开头
|
||||
@@ -17,37 +19,37 @@ class SocketTerminal {
|
||||
foreground: '#ECECEC', // 字体
|
||||
background: '#000000', // 背景色
|
||||
cursor: 'help', // 设置光标
|
||||
lineHeight: 20,
|
||||
},
|
||||
fontSize: 18,
|
||||
fontWeight: 'normal',
|
||||
})
|
||||
this.fitAddon = new FitAddon()
|
||||
this.term.loadAddon(this.fitAddon)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import sessionStore, { init } from '../store/session'
|
||||
import GameHeaderPart from '../components/GameHeaderPart.vue'
|
||||
// import CommandlistPart from '../components/CommandlistPart.vue'
|
||||
import HintPart from '../components/HintPart.vue'
|
||||
import QuestPart from '../components/QuestPart.vue'
|
||||
import TerminalPart from '../components/TerminalPart.vue'
|
||||
import VisualizationPart from '../components/VisualizationPart.vue'
|
||||
import ControlPalette from '../components/ControlPalette.vue'
|
||||
import CompleteModal from '../components/CompleteModal.vue'
|
||||
import { LoadQuestError, LoadSessionError } from '../utils/errors'
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import useSession from '../store/session'
|
||||
import useUserProfile from '../store/userProfile'
|
||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||
import DescriptionPart from '../components/game/DescriptionPart.vue'
|
||||
import QuestPart from '../components/game/QuestPart.vue'
|
||||
import TerminalPart from '../components/game/TerminalPart.vue'
|
||||
import VisualizationPart from '../components/game/VisualizationPart.vue'
|
||||
import ControlPalette from '../components/game/ControlPalette.vue'
|
||||
import CompleteModal from '../components/game/CompleteModal.vue'
|
||||
import StartButton from '../components/game/StartButton.vue'
|
||||
import { openQuestSurvey } from '../utils/formbricks'
|
||||
|
||||
const sessionStore = useSession()
|
||||
const userStore = useUserProfile()
|
||||
|
||||
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({
|
||||
@@ -23,45 +34,32 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await init(props.questId)
|
||||
} catch (err) {
|
||||
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
|
||||
}
|
||||
console.error(err)
|
||||
toast.error(err.message)
|
||||
}
|
||||
sessionStore.reset()
|
||||
sessionStore.setup()
|
||||
await sessionStore.setQuest(props.questId)
|
||||
await sessionStore.getActiveSession()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- game header -->
|
||||
<div class="h-[6vh] w-full">
|
||||
<GameHeaderPart :title="sessionStore.quest?.title" />
|
||||
<div class="w-full">
|
||||
<HeaderPart
|
||||
:title="sessionStore.quest?.title"
|
||||
:headerComponent="GameHeaderComponents"
|
||||
/>
|
||||
</div>
|
||||
<!-- current status indicator -->
|
||||
<input
|
||||
type="hidden"
|
||||
id="currentStatus"
|
||||
:value="sessionStore.session.status"
|
||||
:value="sessionStore.session?.status"
|
||||
/>
|
||||
<!-- 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 -->
|
||||
<div class="bg-bg-secondary h-full w-1/3 rounded-lg">
|
||||
<div class="bg-bg-secondary h-full w-1/4 rounded-lg">
|
||||
<section id="quest" class="h-full p-3 overflow-auto">
|
||||
<QuestPart />
|
||||
</section>
|
||||
@@ -71,17 +69,18 @@ onMounted(async () => {
|
||||
</section> -->
|
||||
</div>
|
||||
<!-- Terminal and Hint -->
|
||||
<div class="bg-bg h-full w-1/3 rounded-lg">
|
||||
<section id="terminal" class="h-3/5 overflow-hidden">
|
||||
<TerminalPart />
|
||||
<div class="bg-bg h-full rounded-lg w-1/2">
|
||||
<StartButton :questId="$props.questId" />
|
||||
<section id="hint" class="h-3/5">
|
||||
<DescriptionPart />
|
||||
</section>
|
||||
<hr class="border-border border" />
|
||||
<section id="hint" class="h-2/5">
|
||||
<HintPart />
|
||||
<section id="terminal" class="h-2/5 overflow-hidden">
|
||||
<TerminalPart />
|
||||
</section>
|
||||
</div>
|
||||
<!-- Visualization -->
|
||||
<div class="h-full w-1/3">
|
||||
<div class="h-full w-1/4">
|
||||
<div class="bg-border rounded-xl h-[90%] p-2">
|
||||
<div class="bg-bg rounded-lg h-full p-2">
|
||||
<section id="visualization" class="bg-bg-primary h-full rounded-md">
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import ContactButton from '../components/ContactButton.vue'
|
||||
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||
import LandingHeaderComponents from '../components/header/LandingHeaderComponents.vue'
|
||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
|
||||
import { facebookUrl, githubUrl } from '../config'
|
||||
</script>
|
||||
<template>
|
||||
<div class="w-screen h-screen bg-scroll bg-bg">
|
||||
<DynamicBackground class="w-full h-full" />
|
||||
<div class="h-screen w-screen absolute top-0 left-0">
|
||||
<!-- game header -->
|
||||
<HeaderPart :headerComponent="LandingHeaderComponents" class="z-50" />
|
||||
<!-- main content -->
|
||||
<main class="w-full h-full overflow-y-auto">
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<div
|
||||
class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10"
|
||||
>
|
||||
<h1 class="text-text-primary text-7xl font-extrabold font-mono">
|
||||
Linux
|
||||
</h1>
|
||||
<img src="../img/icon_totem.svg" class="h-[30vh]" />
|
||||
<h1 class="text-text-primary text-7xl font-extrabold font-mono">
|
||||
Odyssey
|
||||
</h1>
|
||||
</div>
|
||||
<!-- intro -->
|
||||
<div class="w-full md:w-2/3 px-5">
|
||||
<section
|
||||
id="intro"
|
||||
class="text-text grid grid-cols-1 gap-3 justify-items-center"
|
||||
>
|
||||
<h1
|
||||
class="text-text inline-block bg-bg-primary p-5 rounded-xl opacity-90"
|
||||
style="font-size: 2.5vh"
|
||||
>
|
||||
程式設計不僅僅是寫程式碼,還涉及到如何有效地與你的電腦互動。
|
||||
程式教育卻往往忽略背後「開發環境」的重要性,
|
||||
使得許多學生在過程中充滿挫折。
|
||||
「開發環境」是寫程式、執行程式過程所需要的一切工具。
|
||||
而這些工具需要在終端機輸入指令來使用。
|
||||
大部分的程式要用來運行的環境,並非 Windows 或 Mac OS,而是 Linux
|
||||
作業系統。 Linux Odyssey 即是為此設計的線上 Linux 互動教學網站。
|
||||
</h1>
|
||||
</section>
|
||||
<section
|
||||
id="feature"
|
||||
class="text-text p-15 grid grid-rows-3 gap-10 items-center pt-10"
|
||||
>
|
||||
<div
|
||||
class="bg-bg-primary p-5 rounded-xl opacity-90 grid grid-cols-3 grid-rows-2 justify-items-center items-center"
|
||||
>
|
||||
<img
|
||||
src="../img/icon_shuttle.svg"
|
||||
class="h-[18vh] row-span-2"
|
||||
/>
|
||||
<h1
|
||||
class="text-text-primary p-5 font-extrabold font-mono col-span-2"
|
||||
style="font-size: 3.5vh"
|
||||
>
|
||||
初學者也能立即上手
|
||||
</h1>
|
||||
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
|
||||
我們明白對於初學者來說,設定和使用開發工具可能是一大障礙。
|
||||
學習者無需安裝任何軟體,僅需通過瀏覽器即可連上一個真實的 Linux
|
||||
環境。
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="bg-bg-primary p-5 rounded-xl opacity-90 grid grid-cols-3 grid-rows-2 justify-items-center items-center"
|
||||
>
|
||||
<h1
|
||||
class="text-text-primary p-5 font-extrabold font-mono col-span-2"
|
||||
style="font-size: 3.5vh"
|
||||
>
|
||||
遊戲般的學習體驗
|
||||
</h1>
|
||||
<img src="../img/icon_sword.svg" class="h-[18vh] row-span-2" />
|
||||
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
|
||||
Linux Odyssey 融合了遊戲的樂趣與學習的實用性。
|
||||
透過精心設計的遊戲關卡,學習者可以一邊接受挑戰、破解謎題,
|
||||
一邊掌握 Linux 指令的運用。
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="bg-bg-primary p-5 rounded-xl opacity-90 grid grid-cols-3 grid-rows-2 justify-items-center items-center"
|
||||
>
|
||||
<img
|
||||
src="../img/icon_settings.svg"
|
||||
class="h-[18vh] row-span-2"
|
||||
/>
|
||||
<h1
|
||||
class="text-text-primary p-5 font-extrabold font-mono col-span-2 pt-8"
|
||||
style="font-size: 3.5vh"
|
||||
>
|
||||
真實的 Linux 操作之旅
|
||||
</h1>
|
||||
<h1 class="text-text p-2 col-span-2" style="font-size: 2.5vh">
|
||||
Linux Odyssey 提供的不僅是理論知識,更是真實的操作體驗。
|
||||
每一個指令都是在真實的 Linux 環境中執行,
|
||||
讓學習者能夠真正深入軟體開發的世界。
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section class="w-full py-10">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/XzvfmUf1QP8?si=QxnKJMNjsm27fGGF"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
class="w-full aspect-video"
|
||||
></iframe>
|
||||
</section>
|
||||
<section
|
||||
id="start"
|
||||
class="text-text bg-bg-secondary border-8 border-text-tertiary rounded-xl transition ease-in-out hover:scale-110 duration-300"
|
||||
>
|
||||
<RouterLink title="StartGame" id="StartGame" to="/register">
|
||||
<div class="text-text text-xl p-2 w-full text-center">
|
||||
準備好鍵盤,跟我們一起開啟你的 Linux 冒險吧!
|
||||
</div>
|
||||
</RouterLink>
|
||||
</section>
|
||||
<section
|
||||
id="about"
|
||||
class="text-text flex flex-col items-center mt-10 gap-10"
|
||||
>
|
||||
<div class="w-full flex flex-col items-center gap-2">
|
||||
<h1 class="text-text-primary text-3xl font-extrabold font-mono">
|
||||
團隊介紹
|
||||
</h1>
|
||||
<p class="text-lg">國立臺北科技大學 智慧自動化工程科 第二屆</p>
|
||||
<div class="flex flex-row gap-5 justify-center">
|
||||
<h1 class="font-bold">Wancat</h1>
|
||||
<h1 class="font-bold">Neil</h1>
|
||||
<h1 class="font-bold">A.H.</h1>
|
||||
<h1 class="font-bold">Ray Gur</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex flex-col items-center gap-2">
|
||||
<h1 class="text-text-primary text-3xl font-extrabold font-mono">
|
||||
得獎肯定
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
大專校院資訊應用服務創新競賽<br />
|
||||
鈦坦敏捷開發特別獎 第二名
|
||||
</h1>
|
||||
</div>
|
||||
<div class="container mx-auto">
|
||||
<div
|
||||
class="justify-items-center grid grid-cols-1 sm:grid-cols-3 gap-5"
|
||||
>
|
||||
<h1
|
||||
class="text-text-primary text-3xl font-extrabold font-mono sm:col-span-3 gap-6"
|
||||
>
|
||||
玩家感想
|
||||
<!--可以做成翻頁卡牌一次一個-->
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">"很好玩很像益智遊戲"</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"我從來不知道自由軟體的存在,謝謝你們。"
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"還蠻好玩的,讓程式變好玩"
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"以前不喜歡電腦課現在覺得喜歡一點點"
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"程式真的對新手很友善,讓我學到了很多東西"
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"感覺做得很好很簡明易懂關卡設計很用心"
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動"
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"以前我覺得寫程式很難不過現在覺得很有趣"
|
||||
</h1>
|
||||
<h1 class="text-xl text-center">
|
||||
"以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近"
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section
|
||||
id="contact"
|
||||
class="text-text mt-10 flex flex-col items-center"
|
||||
>
|
||||
<h1 class="text-text-primary font-extrabold font-mono text-3xl">
|
||||
聯絡我們
|
||||
</h1>
|
||||
<div
|
||||
class="flex flex-row items-center justify-around mt-5 gap-5 mb-5"
|
||||
>
|
||||
<ContactButton
|
||||
:url="facebookUrl"
|
||||
:icons="['fab', 'facebook']"
|
||||
title="Facebook"
|
||||
desciption="Linux Odyssey"
|
||||
/>
|
||||
<ContactButton
|
||||
:url="githubUrl"
|
||||
:icons="['fab', 'github']"
|
||||
title="GitHub"
|
||||
desciption="linux-odyssey/linux-odyssey"
|
||||
/>
|
||||
<ContactButton
|
||||
url="mailto:team@linuxodyssey.xyz"
|
||||
:icons="['fas', 'envelope']"
|
||||
title="Email"
|
||||
desciption="team@linuxodyssey.xyz"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,18 +1,25 @@
|
||||
<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 HeaderPart from '../components/header/HeaderPart.vue'
|
||||
import GameHeaderComponents from '../components/header/GameHeaderComponents.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('讀取排行榜失敗')
|
||||
}
|
||||
@@ -29,8 +36,8 @@ onMounted(async () => {
|
||||
<div
|
||||
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
|
||||
>
|
||||
<div class="h-[6vh] w-full">
|
||||
<GameHeaderPart title="排行榜" />
|
||||
<div class="w-full">
|
||||
<HeaderPart title="排行榜" :headerComponent="GameHeaderComponents" />
|
||||
</div>
|
||||
<div class="w-fit h-[94vh] m-3">
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
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 {
|
||||
TooManyRequestsError,
|
||||
@@ -9,28 +10,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('出了點問題。')
|
||||
}
|
||||
}
|
||||
@@ -38,10 +46,11 @@ const handleLogin = async ({ username, password, success, error }) => {
|
||||
|
||||
<template>
|
||||
<div class="w-screen h-screen">
|
||||
<Background class="w-full h-full" />
|
||||
<DynamicBackground class="w-full h-full" />
|
||||
<div
|
||||
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
|
||||
>
|
||||
<HeaderPart />
|
||||
<div class="w-fit">
|
||||
<AuthForm @onSubmit="handleLogin" type="login" />
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
|
||||
<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 class="w-fit">
|
||||
<HeaderPart
|
||||
title="關卡地圖"
|
||||
:headerComponent="GameHeaderComponents"
|
||||
class="z-50"
|
||||
/>
|
||||
</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 />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import validator from 'validator'
|
||||
import { isValidUsername } from '@linux-odyssey/utils'
|
||||
import { passwordPolicy } from '@linux-odyssey/constants'
|
||||
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 {
|
||||
UnauthorizedError,
|
||||
@@ -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('出了點問題。')
|
||||
}
|
||||
}
|
||||
@@ -75,11 +89,12 @@ async function check({ username, email, password, error }) {
|
||||
|
||||
<template>
|
||||
<div class="w-screen h-screen">
|
||||
<Background class="w-full h-full" />
|
||||
<DynamicBackground class="w-full h-full" />
|
||||
<div
|
||||
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
|
||||
@onSubmit="handleRegister"
|
||||
@onChange="check"
|
||||
|
||||
@@ -24,6 +24,9 @@ export default {
|
||||
DEFAULT: '#FF0000',
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
catelogbg: "url('/catelogbg.svg')",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"references": [
|
||||
{ "path": "../packages/utils" },
|
||||
{ "path": "../packages/constants" },
|
||||
{ "path": "../packages/file-graph" },
|
||||
{ "path": "../packages/models" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
}
|
||||
@@ -36,6 +36,8 @@ services:
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
||||
- BACKEND_URL=http://backend:3000
|
||||
- BASE_URL=${BASE_URL}
|
||||
|
||||
analytics:
|
||||
image: linuxodyssey/analytics
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
- BASE_URL=http://app-test
|
||||
- TESTING_USERNAME=alex
|
||||
- TESTING_PASSWORD=Alex1234
|
||||
# - DEBUG=cypress:*
|
||||
depends_on:
|
||||
- app-test
|
||||
|
||||
@@ -23,8 +24,9 @@ services:
|
||||
target: prod
|
||||
networks:
|
||||
internal:
|
||||
ports:
|
||||
- 5173:80
|
||||
# Only open the port for debugging
|
||||
# ports:
|
||||
# - 5173:80
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
@@ -43,7 +45,7 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- BASE_URL=http://localhost:5173
|
||||
- BASE_URL=http://app-test
|
||||
- MONGO_URL=mongodb://user:user-password@db-test:27017/odyssey-test
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- SECRET_KEY=b1d2e917-17b9-4fce-9055-082dee6ec5d6 # Only for testing
|
||||
@@ -57,6 +59,7 @@ services:
|
||||
- TESTING=true
|
||||
- TESTING_USERNAME=alex
|
||||
- TESTING_PASSWORD=Alex1234
|
||||
- BACKEND_URL=http://backend:3000
|
||||
|
||||
db-test:
|
||||
image: mongo
|
||||
@@ -70,8 +73,8 @@ services:
|
||||
- MONGO_USER=user
|
||||
- MONGO_PASS=user-password
|
||||
- MONGO_DB=odyssey-test
|
||||
ports:
|
||||
- 27017:27017
|
||||
# ports:
|
||||
# - 27017:27017
|
||||
|
||||
base:
|
||||
image: linuxodyssey/quest-base
|
||||
|
||||
@@ -1,78 +1,8 @@
|
||||
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/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:
|
||||
|
||||
@@ -29,4 +29,38 @@ down() {
|
||||
docker compose -f docker-compose.testing.yml down
|
||||
}
|
||||
|
||||
dump() {
|
||||
# Define service name and dump directory name
|
||||
SERVICE_NAME="db"
|
||||
DUMP_NAME="mongodump-$(date --iso).archive"
|
||||
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
|
||||
|
||||
# Fetch credentials from Docker environment variables
|
||||
MONGO_URL=$(docker compose exec -T backend printenv MONGO_URL)
|
||||
echo mongo uri: $MONGO_URL
|
||||
|
||||
# Dump MongoDB data using credentials
|
||||
docker compose exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME -d odyssey-test" &&
|
||||
|
||||
# Copy the dump from the container to the host
|
||||
# Note: You will need to know the exact container name for this step
|
||||
docker cp ${CONTAINER_ID}:/tmp/$DUMP_NAME . &&
|
||||
|
||||
echo "MongoDB data dumped successfully."
|
||||
}
|
||||
|
||||
restore() {
|
||||
# Define service name and dump directory name
|
||||
SERVICE_NAME="db"
|
||||
DUMP_NAME=$1
|
||||
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
|
||||
|
||||
# Copy the dump from the host to the container
|
||||
docker cp $DUMP_NAME ${CONTAINER_ID}:/tmp &&
|
||||
|
||||
# Dump MongoDB data using credentials
|
||||
docker compose exec -T $SERVICE_NAME sh -c "mongorestore --gzip --archive=/tmp/$DUMP_NAME" &&
|
||||
|
||||
echo "MongoDB data dumped successfully."
|
||||
}
|
||||
$@
|
||||
@@ -1,5 +0,0 @@
|
||||
BASE_URL=https://example.com
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITNUB_CLIENT_SECRET=
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
import type { Config } from 'jest'
|
||||
|
||||
const config: Config = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls, instances, contexts and results before every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: 'v8',
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// The default configuration for fake timers
|
||||
// fakeTimers: {
|
||||
// "enableGlobally": false
|
||||
// },
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "mjs",
|
||||
// "cjs",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "json",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1', // Redirect .js imports to .ts files
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state and implementation before every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
// testEnvironment: "jest-environment-node",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -13,13 +13,13 @@
|
||||
],
|
||||
"scripts": {
|
||||
"app": "yarn workspace app dev",
|
||||
"app:local": "yarn workspace app dev:local",
|
||||
"build": "yarn workspace app build",
|
||||
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
|
||||
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
|
||||
"server": "yarn workspace server dev",
|
||||
"cli": "yarn workspace client start",
|
||||
"lint": "eslint --ext .js,.vue .",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --ignore-path .prettierignore --write app server",
|
||||
"test": "yarn node --experimental-vm-modules $(yarn bin jest)",
|
||||
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
|
||||
"test:e2e": "yarn workspace tests test",
|
||||
"test:open": "yarn workspace tests test:open",
|
||||
"analytics": "yarn workspace analytics dev",
|
||||
@@ -28,14 +28,33 @@
|
||||
"build:quests": "yarn workspace server build:quests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.24.0",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vue/eslint-config-prettier": "^7.1.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-cypress": "^2.15.1",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"prettier": "^3.0.0"
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"latest": "^0.2.0",
|
||||
"typescript-eslint": "^7.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ADMIN_USERNAME=alex
|
||||
ADMIN_PASSWORD=Alex1234
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./package.json yarn.lock ./
|
||||
COPY ./package.json yarn.lock tsconfig.json ./
|
||||
COPY packages/analytics/package.json ./packages/analytics/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
RUN yarn install --frozen-lockfile --production
|
||||
RUN yarn install --frozen-lockfile
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3001
|
||||
EXPOSE 3001
|
||||
@@ -15,11 +15,5 @@ COPY packages/analytics ./packages/analytics
|
||||
COPY packages/models ./packages/models
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app/packages/analytics
|
||||
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" ]
|
||||
RUN yarn build
|
||||
CMD [ "yarn", "start" ]
|
||||
@@ -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,16 +6,27 @@
|
||||
"type": "module",
|
||||
"license": "GPL",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc -b -f",
|
||||
"watch": "tsc -b --watch",
|
||||
"dev": "nodemon"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linux-odyssey/models": "*",
|
||||
"express": "^4.18.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"express-handlebars": "^7.1.2"
|
||||
"express-handlebars": "^7.1.2",
|
||||
"mongoose": "^8.0.3"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ th {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
border: 2px solid black;
|
||||
border: 2px solid #dcdcdd;
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -17,3 +17,117 @@ td {
|
||||
width: 1px;
|
||||
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,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 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
|
||||
@@ -1,9 +0,0 @@
|
||||
import { errorCommands } from '../models/commands.js'
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function commandListController(req, res) {
|
||||
const payload = {
|
||||
errorCommands: await errorCommands(),
|
||||
}
|
||||
res.render('commands', payload)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import Pagination from '../models/pagination.js'
|
||||
import { errorCommands } from '../models/commands.js'
|
||||
|
||||
export async function commandListController(
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { nextKey, order } = req.query as { nextKey?: string; order: string }
|
||||
const itemsPerPage = 100
|
||||
|
||||
try {
|
||||
const pagination = new Pagination(order, itemsPerPage, nextKey)
|
||||
const payload = await errorCommands(pagination)
|
||||
const newNextKey = payload[payload.length - 1]?._id
|
||||
|
||||
res.render('commands', {
|
||||
errorCommands: payload,
|
||||
nextKey: newNextKey,
|
||||
order: pagination.getOrder(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
res.status(500).send('Error fetching data')
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { userCount } from '../models/users.js'
|
||||
import { sessionCount, sessionCompleted } from '../models/sessions.js'
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function homeController(req, res) {
|
||||
const payload = {
|
||||
title: 'Linux Odyssey Admin',
|
||||
userCount: await userCount(),
|
||||
sessionCount: await sessionCount(),
|
||||
sessionCompleted: await sessionCompleted(),
|
||||
}
|
||||
payload.completedRate =
|
||||
Math.round((payload.sessionCompleted / payload.sessionCount) * 10000) / 100
|
||||
res.render('home', payload)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { userCount } from '../models/users.js'
|
||||
import { questList } from '../models/quests.js'
|
||||
|
||||
export async function homeController(req: Request, res: Response) {
|
||||
const quests = await questList()
|
||||
const [totalQuests, totalCompleted] = quests.reduce(
|
||||
([total, completed], quest) => [
|
||||
total + quest.started,
|
||||
completed + quest.completed,
|
||||
],
|
||||
[0, 0]
|
||||
)
|
||||
|
||||
const payload = {
|
||||
title: 'Linux Odyssey Admin',
|
||||
userCount: await userCount(),
|
||||
totalQuests,
|
||||
totalCompleted,
|
||||
completedRate: Math.round((totalCompleted / totalQuests) * 10000) / 100,
|
||||
}
|
||||
res.render('home', payload)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { questList } from '../models/quests.js'
|
||||
|
||||
export async function questListController(req: Request, res: Response) {
|
||||
const quests = await questList()
|
||||
quests.forEach((quest) => {
|
||||
quest.failed = quest.started - quest.completed
|
||||
})
|
||||
res.render('quests', { quests })
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { sessionList, sessionDetail } from '../models/sessions.js'
|
||||
|
||||
export async function sessionListController(req, res) {
|
||||
const sessions = await sessionList()
|
||||
res.render('sessions', { sessions })
|
||||
}
|
||||
|
||||
export async function sessionDetailController(req, res) {
|
||||
const { id } = req.params
|
||||
const session = await sessionDetail(id)
|
||||
res.render('sessionDetail', { session })
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Request, Response } from 'express'
|
||||
import Pagination from '../models/pagination.js'
|
||||
import { sessionList, sessionDetail } from '../models/sessions.js'
|
||||
|
||||
export async function sessionListController(req: Request, res: Response) {
|
||||
const { nextKey, order } = req.query as { nextKey: string; order: string }
|
||||
const itemsPerPage = 50
|
||||
|
||||
try {
|
||||
const pagination = new Pagination(order, itemsPerPage, nextKey)
|
||||
const sessions = await sessionList(pagination)
|
||||
const newNextKey = sessions[sessions.length - 1]?._id
|
||||
|
||||
res.render('sessions', {
|
||||
sessions,
|
||||
nextKey: newNextKey,
|
||||
order: pagination.getOrder(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
res.status(500).send('Error fetching session data')
|
||||
}
|
||||
}
|
||||
|
||||
export async function sessionDetailController(req: Request, res: Response) {
|
||||
const { id } = req.params
|
||||
const session = await sessionDetail(id)
|
||||
res.render('sessionDetail', { session })
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { userList } from '../models/users.js'
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function userListController(req, res) {
|
||||
res.render('users', { users: await userList() })
|
||||
}
|
||||