Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ad1a7b50 | ||
|
|
cb4b9f429a | ||
|
|
5e11d2da11 | ||
|
|
a901ce005c | ||
|
|
4dd2a7cbe1 | ||
|
|
5cf375905d | ||
|
|
d7c5c756f3 | ||
|
|
44ecd3b87a | ||
|
|
1888781a47 | ||
|
|
860b257ea7 | ||
|
|
ba251fb933 | ||
|
|
84bd2620d8 | ||
|
|
b449d84c54 | ||
|
|
91ebaec22c | ||
|
|
58354b7cd8 | ||
|
|
31301419ad | ||
|
|
251139c159 | ||
|
|
a4ed4cf533 | ||
|
|
247f03b71a | ||
|
|
be22a121c8 | ||
|
|
261c6fc029 | ||
|
|
03b2993b56 | ||
|
|
2cc348687a | ||
|
|
2a3429106c | ||
|
|
a6720cfe39 | ||
|
|
4c292fd6ba | ||
|
|
5ba0021c37 | ||
|
|
19897e607f | ||
|
|
08e9191c4b | ||
|
|
aa95eda23c | ||
|
|
6bc37900d8 | ||
|
|
98f18627e5 | ||
|
|
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 | ||
|
|
b41e67ee0b | ||
|
|
50a0bbbab1 | ||
|
|
d712925d0f | ||
|
|
b78398680d | ||
|
|
c7f52b1003 | ||
|
|
f008fb9f60 | ||
|
|
76a39add29 | ||
|
|
eb9118d5b1 | ||
|
|
cfdf8730e8 | ||
|
|
6651071580 |
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
|
||||
@@ -0,0 +1,50 @@
|
||||
HOST=localhost
|
||||
PORT=3000
|
||||
BASE_URL=https://example.com
|
||||
SECRET_KEY=YOU_SHOULD_CHANGE_THIS
|
||||
BACKEND_URL=http://backend:3000
|
||||
|
||||
MONGO_ROOT_PASSWORD=adminpassword
|
||||
MONGO_USER=commander
|
||||
MONGO_PASS=commander
|
||||
MONGO_DB=linux-odyssey
|
||||
MONGO_URL=mongodb://commander:commander@db:27017/linux-odyssey
|
||||
|
||||
# Admin user credentials for analytics
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
ADMIN_HOST=0.0.0.0
|
||||
ADMIN_PORT=3001
|
||||
# ADMIN_BASE_URL=http://0.0.0.0:3001
|
||||
|
||||
# Optional
|
||||
# CONTAINER_EXPIRY=60 # 60 minutes
|
||||
# SESSION_MAX_AGE=168 # 7 days
|
||||
# TRUSTED_PROXIES=1.2.3.4
|
||||
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
# DOCKER_NETWORK=
|
||||
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
|
||||
# Docker related config
|
||||
# DOCKER_NETWORK=host
|
||||
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||
# DOCKER_PREFIX=linuxodyssey/quest-
|
||||
|
||||
# To mount quest folder from host into container, for development purpose
|
||||
# MOUNT_QUEST=true
|
||||
|
||||
# LOG_PATH=
|
||||
|
||||
# Testing, create a user with the following credentials, disable rate limiting
|
||||
# TESTING=true
|
||||
# TESTING_USERNAME=alex
|
||||
# TESTING_PASSWORD=Alex1234
|
||||
@@ -0,0 +1,38 @@
|
||||
HOST=localhost
|
||||
PORT=3000
|
||||
BASE_URL=http://localhost:5173
|
||||
MONGO_URL=mongodb://localhost:27017/odyssey-test
|
||||
SECRET_KEY=sample-secret
|
||||
|
||||
# Optional
|
||||
# BACKEND_URL=http://host.docker.internal:3000
|
||||
# CONTAINER_EXPIRY=60 # 60 minutes
|
||||
# SESSION_MAX_AGE=168 # 7 days
|
||||
# TRUSTED_PROXIES=1.2.3.4
|
||||
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
# DOCKER_NETWORK=
|
||||
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
|
||||
# Docker related config
|
||||
# DOCKER_NETWORK=host
|
||||
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||
# DOCKER_PREFIX=linuxodyssey/quest-
|
||||
|
||||
# To mount quest folder from host into container, for development purpose
|
||||
# MOUNT_QUEST=true
|
||||
|
||||
# LOG_PATH=
|
||||
|
||||
# Testing, create a user with the following credentials, disable rate limiting
|
||||
# TESTING=true
|
||||
# TESTING_USERNAME=alex
|
||||
# TESTING_PASSWORD=Alex1234
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
config
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -52,6 +54,9 @@ web_modules/
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# TypeScript build
|
||||
built
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
@@ -82,6 +87,7 @@ web_modules/
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.prod
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
@@ -132,3 +138,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
|
||||
}
|
||||
@@ -5,9 +5,15 @@
|
||||
|
||||
## Requirements
|
||||
|
||||
- NodeJS
|
||||
- NodeJS v20+
|
||||
- Docker
|
||||
- Yarn
|
||||
- Yarn: v1.22
|
||||
|
||||
## Known Issues
|
||||
|
||||
- MongoDB requires x86 architecture to run for v5+ versions. You can track [this issue](https://github.com/linux-odyssey/linux-odyssey/issues/166) for updates.
|
||||
|
||||
Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for setup instructions.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -15,23 +21,66 @@
|
||||
cd linux-odyssey
|
||||
yarn install
|
||||
|
||||
## Development
|
||||
## Project Structures
|
||||
|
||||
Run everything (frontend, backend, db, swagger, quests):
|
||||
1. **app**: frontend
|
||||
2. **server**: backend
|
||||
3. **quests**: tutorials scripts for each quest
|
||||
4. **packages**: other packages
|
||||
1. **analytics**: admin dashboard to analyze collected data
|
||||
2. **constants**: some shared config values for app and server
|
||||
3. **container**: the script run in container to capture users' command
|
||||
4. **file-graph**: the algorithm to construct and modify file graph, used in both app and server
|
||||
5. **models**: definitions of MondoDB collections (data schema)
|
||||
6. **utils**: other utility code shared between packages
|
||||
7. **game (in the future)**: core logic of game playing, will be extracted from server
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration is stored in the `.env` file. You can copy from the sample file.
|
||||
|
||||
cp .env.sample .env
|
||||
|
||||
The backend will use a SSH key pair to login to the player's container. The private key is on the host, and the public key is copied to the player's container.
|
||||
|
||||
The key pair is stored in `./config/ssh_key` and `./config/ssh_key.pub`. If it doesn't exist, the backend will generate a new pair and store them.
|
||||
|
||||
## Docker Setup
|
||||
|
||||
### Docker compose files explaination
|
||||
|
||||
1. **docker-compose.yml**: The one used for development. It sets up MongoDB and builds base quest image.
|
||||
2. **docker-compose.prod.yml**: The one used for production deployment. It has a Nginx service, a backend, a database, an analytics service, and creates isolated networks for player containers.
|
||||
3. **docker-compose.testing.yml**: Similar to production config, but sets up an one-time environment for end-to-end testing.
|
||||
|
||||
### Common Development Setup
|
||||
|
||||
This sets up a development database, and running development server for frontend and backend.
|
||||
|
||||
Pull the latest images:
|
||||
|
||||
docker compose pull
|
||||
|
||||
Start the containers:
|
||||
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
If you want to enable social login, you should have OAuth client token in a `.env` file:
|
||||
This will start the db. it will automatically restarts by Docker.
|
||||
|
||||
docker compose --env-file .env up -d
|
||||
Building quest images:
|
||||
|
||||
### Build Quest Images
|
||||
docker compose build base
|
||||
yarn build:quests
|
||||
|
||||
In each quest folder, there is a `game.yml` file, which is the quest definition. And a optional `Dockerfile` to overwrite the base image. If you want to include additional files, put them in a `home` folder. The files in `home` will **not** be copied to the image automatically, you have to copy them in the `Dockerfile`.
|
||||
### Build Quest Images in Details
|
||||
|
||||
In each quest folder, there is a `game.yml` file, which is the quest definition.
|
||||
|
||||
If you want to include additional files, put them in a `home` folder. The files in `home` will be copied to the user's home on container creation automatically.
|
||||
|
||||
quests/
|
||||
├── Dockerfile # Base image
|
||||
├── entrypoint.sh # Entrypoint for the container
|
||||
├── discover
|
||||
│ └── game.yml
|
||||
├── helloworld
|
||||
@@ -47,14 +96,55 @@ In each quest folder, there is a `game.yml` file, which is the quest definition.
|
||||
└── home
|
||||
└── ancient_scroll.txt
|
||||
|
||||
An optional `Dockerfile` can overwrite the base image. You can use this to install additional dependencies or modify the environment.
|
||||
|
||||
Customized `entrypoint.sh` for each quest is not yet supported.
|
||||
|
||||
To build all quests:
|
||||
|
||||
yarn build:quests
|
||||
|
||||
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
|
||||
|
||||
### Optional: Mounting Host Directory
|
||||
|
||||
To improve the development experience, we can mount the host directory to the container in the pattern:
|
||||
|
||||
/path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
|
||||
/path/to/linux-odyssey/packages/container:/usr/local/lib/container
|
||||
|
||||
You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables:
|
||||
|
||||
MOUNT_QUEST=true
|
||||
|
||||
## Development
|
||||
|
||||
Copy `.env.sample` to `.env` and customize it for your needs.
|
||||
|
||||
Build the whole project for the first time:
|
||||
|
||||
yarn build
|
||||
|
||||
This will build the typescript for each package and the server.
|
||||
|
||||
Run dev server (frontend, backend):
|
||||
|
||||
yarn dev
|
||||
|
||||
**IMPORTANT NOTE**: only the app and server have hot-reloading, other packages require manually running `yarn dev` in each package.
|
||||
|
||||
To develop analytics dashboard:
|
||||
|
||||
yarn analytics
|
||||
|
||||
The default username and password is `alex` and `Alex1234`.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests:
|
||||
|
||||
yarn test:unit
|
||||
|
||||
Run Cypress locally, using the current development containers:
|
||||
|
||||
docker compose up -d
|
||||
@@ -71,76 +161,22 @@ Run Cypress in container, create brand-new containers along with it:
|
||||
# tearing off the containers
|
||||
./docker-scripts.sh down
|
||||
|
||||
### Deployment
|
||||
## Deployment
|
||||
|
||||
# Build the images (only allowed for maintainers)
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml push
|
||||
|
||||
# Server side
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
yarn build:quests
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
### Necessary Configuration
|
||||
|
||||
To enable social login, you should have OAuth client token in a `.env` file:
|
||||
You can copy from `example.env`
|
||||
You can copy from `.env.sample`
|
||||
|
||||
# .env
|
||||
BASE_URL=https://example.com
|
||||
GOOGLE_CLIENT_ID=...
|
||||
GOOGLE_CLIENT_SECRET=...
|
||||
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
|
||||
|
||||
To enable it:
|
||||
|
||||
docker compose --env-file .env -f docker-compose.prod.yml up -d
|
||||
|
||||
## Standalone App (without Docker)
|
||||
|
||||
Source: `app/`
|
||||
|
||||
# Connect to dev server (https://odyssey.wancat.cc)
|
||||
yarn app
|
||||
|
||||
# Connect to localhost (http://localhost:3000)
|
||||
yarn app:local
|
||||
|
||||
# Connect to anyhost
|
||||
API_ENDPOINT=http://example.com yarn app:local
|
||||
|
||||
## Swagger
|
||||
|
||||
docker compose up -d swagger
|
||||
|
||||
Open http://localhost:8080 to open Swagger
|
||||
|
||||
## CLI terminal client (Docker not required)
|
||||
|
||||
yarn cli
|
||||
Usage: client [options] [command]
|
||||
|
||||
Options:
|
||||
-s, --session <string> Session ID
|
||||
-c, --create Create a new session
|
||||
-h, --host <string> Server host (default: "http://localhost:3000")
|
||||
--help display help for command
|
||||
|
||||
Commands:
|
||||
list List all sessions
|
||||
|
||||
connet to recent session:
|
||||
|
||||
yarn cli
|
||||
|
||||
connect to new session:
|
||||
|
||||
yarn cli -c
|
||||
|
||||
connect to specific session:
|
||||
|
||||
yarn cli -s 64d0de0367459c13004bc83f
|
||||
|
||||
connect to other host:
|
||||
|
||||
yarn cli -h https://example.com
|
||||
|
||||
list sessions:
|
||||
|
||||
yarn cli list
|
||||
SECRET_KEY=your-secret-key
|
||||
@@ -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,14 +83,29 @@ 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="text-error inline-flex justify-center items-center xl:hidden">
|
||||
<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']"
|
||||
class="px-1 text-sm"
|
||||
@@ -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,190 +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 = 200
|
||||
const marginY = 65
|
||||
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 / 10, fullwidth / 25],
|
||||
roam: 'move',
|
||||
zoom: 1,
|
||||
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 '454552'
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
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,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 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,iVBORw0KGgoAAAANSUhEUgAAAdAAAAHQCAYAAAARXTsAAAAgAElEQVR4nOy9CZhcVZ33/z239up9qaQ7O5uOoi+iOEKW7vSQGXyRGed1Bl7GEWV3f9VBdPzraMBlZhx13EYxpJuIgkpUVlGUEBIWwUSBgAGyp5Ne0mt679ru+T+nOgWdTnfXdpdzzv19noeHTnfVvefe7qpPne37Y7AAzrkVhyEchjFLfv2EDVy5lYcNA99iDBFfFB/YcB4bp/tMEO5y7Nix003TXM05X2UYhkEC9TgkUfm4chs/y8exGQznnGjciybDP97WxHZ7/d4QhFNwzn1dXV1vArAawCoALQDqxekNw3jU5/N1lPzuSfJUH5KoPFy7nb+Tc2wCUD2jUSMcuLatmd3l5ftDEHbR2dkZBfBm0bsU0mSMrQFQNfN0J+S5FsAHSKBEBpKou6zfyv1HDHwJwKfEr2OuxnBgw0g9Prr5bJbwyK0hCFvo7++vnJyc/EvDMNZxzkUv8zwAofnONU2e4uuzS3rXJHnqBUnUHd67hS8O+PGzE8NE+bAzbeLSTS3skMduFUEUTXt7+6JAILDqhCzFa+1c4cF8jzddngD66uvrF5BAiVcggTrP1Y/yFsZwJ4CGAk/eBxNXtLaw3+hwHwjCaqYv+AGwDsDpxZ5ihjwF98Zisb8v+h2T5KknJFGH4Jxdsz0zXPtlAL4iTypehF8dPobPbr6MpZW7BwRhEfMt+CmVWeQp+GQsFvs6CZQ4CRKo/Vy/ldenffgRON5u0cm2pk28e1ML65b5ugnCKvJd8FMqc8hTvE++rb6+/g8kUOIkSKD2cvVWfh4zsBnACitPxIAOzvB/W5vYExJeNkGURDELfkplLnkCGKuvr69hjCVJoMRJkEDt49pt/HoOfAdA0KaTpAB8rrUJXwVj9AIllKXUBT+lMo88BVtisZiYU517uXwuSKB6QgK1nqsf5xVGGhs5cJlDp7w3beLKTS3suJPXSRDFYuWCn1LJIU/B+lgsdhNIoMRMSKDWcu02/joO/BzA6x0+9V5u4NK2New5h89LEPNi54KfUslDnuI98sL6+vpHUKxASZ76QgK1jqu38fcy4PsAoi41YZIBH9vYzDa4dH6CcGzBT6nkI08AyXQ6XdPQ0DAGEigxExJo6ZwIgv9PBvw/KRrE8CMKpCecwo0FP6WSpzwFT8disfOz/yCBEq9A8iydWYLgZYEC6QlbcHvBT6kUIE/B12Kx2I3ZfxT8jkny1BcSaGnMEwQvCxRIT5SMTAt+SqVAeQreGYvF7sv+gwRKvAIJtDjyDYKXBQqkJ/JF5gU/pVKEPHkoFIpVVlb2Z79R0Iud5KkvJM/iKCIIXgo4sMM0cRkF0hPTUWXBT6kUIU/Bn2Ox2Bumf4MESmQggRZOCUHwskCB9B5HxQU/pVKkPAW3xGKxD07/Rt7vmiRPfSF5Fog1QfCyQIH0HkL1BT+lUoI8Bf8ci8XunP4NEqjHIXkWhg1B8LKwlSXxTxvXsWOaXZen0WnBT6mUKE/4fL7ltbW17dO/l9e7J8lTT0iehWFXELxEHAXD5RRIryY6L/gplVLlKTrvsVhs+cxvkkA9CsmzMBwIgpcFCqRXBK8s+CkVC+QpuCMWi71n5jdzvouSPPWD5Jk/LgTBywIF0kuGFxf8lIpF8hR8IBaL/WDmN0mgmjBTijN/byTNwnExCF4WKJDeRby+4KdULJSnONbZdXV1p6R4kUAVhYRoLxIEwcsCBdI7BC34sQ4r5Sm2e9XX1y9gs0xpkEAVhORpH9IFwcsCBdJbCi34sQ+L5Sm4NxaL/f1sPyCBKggJ1B4kDoKXBQqkLxJa8OMMNshT8MlYLPb12X4w5zsxiVNOSJ72cPU2/ncM+KHEQfCyQIH0eUALfpzHJnmK99y31dfX/2HWn832TZKnnJA8rUe1IHhZoED6k6EFP+5ilzwBjNXX19cwxpKz/fCUNwySp5yQPK1H1SB4WfByID0t+JEHG+Up2BKLxdbN9UMSqINkJVjMPSaBWosGQfCyoH0gPS34kReb5SlYH4vFbprrhye9K5M87WE2+eV7r0mcFqNXELwsaBVITwt+1MABeYr33wvr6+sfmfPn2S9IntaSj/hy3XOSp7VoHAQvC0oG0tOCH/VwQp4Akul0uqahoWFsrgdk3qFJntZQivDE74CEaR8eCIKXBekD6WnBj9o4JE/B07FY7Pz5HsBInrMjZJa9N9mvSXBq4qEgeFmQKpCeFvzog4PyFHwtFovdON8DSKAnIDnqh4eD4GXB8UB6WvCjLw7LU/DOWCx233wPIIGSPLWEguClwdZAelrw4w1ckCcPhUKxysrK/vke5HmBkjz1g4LgpcOyQHpa8OM9XJCn4M+xWOwNuR7kWYGSOPWDguAlp4hAelrw421ckqfgllgs9sFcD/KkQEme+nHNVn4mGH5OQfDS8yID/mFjM3sxV0M7OzuvA0Bl1DyKi/IU/HMsFrsz14M89UlOiJPkqR8iCB4GdpA8lUDMTT999TY+78Kujo4O0dP8ltdvlldxWZ7w+XyP5/M4z/RASZz6QUHwajNXIP3Ro0frDMPYAeA0r98jL+K2PMXMQSwWW57PA7XugWZ7nCRP/RBB8EcMPArg0yRPNWHA9RV9ePzKrfyVcAuxDcUwjDtJnt5EAnkKHsv3gTQZTyiHCIIP+LGTqqioDwPe6jOw47rt/CJMzXv+O4C/8fp98SKSyBOFCNRvbzvcg3qd+nHpXdxXsRCfY8Dn6cOfVtSbHA/e9+LxbzA2foPXb4YXYYxtk0SeQuQkUEIvrn2YL+QB3E69Ez1ZGEwefkvV+PtpON57SNTzFPTV1tbmXCGeRUuBUu9TL659lP81Zxl5Uu1ODQkxPvaBpf0mAyq8fi+8hmTyFDzBCshv1k6gJE99uH4nD5hj+CwH/o2GbPWEAfyDy3qf9xt83qoXhH5IKE8UMv8JGsIlZOXabfy09BjEakx6Y9WYdy08vq0umJbtTZSwGUnlKTpgBQlUq0/11PvUA5Fly4FdJE+9eX15/Jk3VU6s8fp98BqyyhPAWF1d3TOFPEGbHijJU31E+TFm4n/AcYXX74Xu1ATSnZc3DCwToS9evxdeQmJ5Cp5ijCULeYIWPVCSp/pc9yh/K0vjjyRP/fExPvnhpb3HGUOd1++Fl5Bcnih0/hM69EBJnmojFgqlx/AvJvBFAAGv3w8vcP3S/h0hH6ehWw+hgDwLnv+E6gIlearNtdv5/0qPYdOJElWEB1hXP/LYolCS5OkhVJAngGQqlXq60CcpK1CSp7qcCIG/gXPcDCDo9fvhFZZFEi8114ye5/X74CUUkafgTw0NDWOFPklJgZI81eXKbfyNR5Hpdb7Z6/fCS5T5zYFrFveXA4h4/V54BYXkiWLmP6GaQEmc6pLtdQK4mVOv01MYDOmPLO87aDC8xev3wisoJk9oK1CSpvpc9zg/+0g60+uk4TsP8p7G/sfLjXSz1++DV1BQnjwUCj1RzBOlFSiJU32u38mj5jg+a6bxSZrr9CbnVY7//qyyRJPX74NXUFCegt2VlZX9xTxROoGSOPXgusf4JekxfJsKI3uXhcHkwb9bOPQGqrDiDRSVJ4odvoVsAiV5qs/Vj/NFzMR/mCYFIngZqrDiLRSWJ7QQKMlTbU4sEvow0plABHrT9DBUYcVbKC5P+Hy+x4t9rusCJXGqz9Vb+eqjBr4H4I1evxcEVVjxEqrLE0B7bW1te7FPdk2gJE71+eBjvCZuYj0DPsKpXidBFVY8hQbyRCnDt3BLoCRPtTmRX/uhhInPM6DW6/eDmKI6kO6gCiveQBN5QjmBkjzV5uqtfF16FN8Ew9levxfEq/gYj39kae8QY1hMt0VvNJKnuBZ1BEryVJdrH+NvgYmvc4A2xBOncP3S/j9QhRX90UmeAPpqa2tfLOUAjgmU5Kkm793CF/v9+Dw3ca0u9WMJa6EKK95AM3kKnmCM8VIO4IhASZ7qccVDvCwUxo0APsUpAJyYA6qw4g00lCdKnf+EEwIlearFiQVCVwO4iQMLvX4/iLmJ+sx+qrCiP5rKs6gC2jOxVaAkT3VYz7lxZBv+IT2GLwM4y+v3g5gfUWHloyv6DlGFFb3RVZ4Axurq6p4p9SC2CJTEqQ5ZcR7Zji+B4TVevx+yYJomBro7Mq+luobF4kUlVfuowor+aCxPwVOMsWSpB7FcoCRPNXhFnNvwRTC81uv3QyYSkxPo6WhHKhHPtCoZn0RsyQr4/HIkb76lavxpqrCiN5rLE1bMfwoY5yUtQjr1gCRQqTkhzncAGXGe4/X7IRvDA30Y6OkCZrwuff4AFixZjlAk6mqLRYWVDy/vq6eQeH3xgDyFpy6sr69/pOTjWClQkqe8CHEefQzv4hxfAPAGr98P2UinUujvOoLx0ZE5WyZeX7WNi1FR5U74U4jx0X89/dgxv8HPcKUBhO14QZ5iUCedTtc0NDSMlXogywRK8pSTS//MgxV9uJwBnxZRpV6/HzIyOT6K3o4jSKfym5KpqK5FbcNiR19zosLKx5b3PF0XTFOFFU3xiDwFT8diMUv+ji2ZVCF5ysc/P8Urw3FcxfpwIwfFq8mI+PA61NeD4/3HgAI+x44cH0AyEUds8XLH5kWpworeeEiesGr+E1YIlOQpF9ds4cvhxwcRxwcAVFk7w01YRSqZQG9HO+IT40UdcXJ8DJ2H9mLB4hUIRezdhkkVVvTGY/KElQItaQiX5CkPVz/Gz2EcN4Djn2QqlE6cytjQIPq7OzJbVUols82lcQnKq2psudM1gXTnJ5b3hMRp6FepHx6UJw+FQrHKysp+Kw5W9BstydN9xMKg9m24iDHcABMXev1+yE52b+fo0KBlLRUfgPs6j2S2utTEGjOTlVbhY3zyw0t7jzNGc+cLine 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',
|
||||
],
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3'
|
||||
services:
|
||||
app:
|
||||
image: linuxodyssey/frontend
|
||||
@@ -29,13 +28,14 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./logs:/app/logs
|
||||
- ./config:/app/config
|
||||
- ./quests:/app/quests:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
||||
- BACKEND_URL=http://backend:3000
|
||||
- HOST_PROJECT_ROOT=${PWD}
|
||||
|
||||
analytics:
|
||||
image: linuxodyssey/analytics
|
||||
@@ -50,13 +50,11 @@ services:
|
||||
- internal
|
||||
ports:
|
||||
- '3001:3001'
|
||||
environment:
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
db:
|
||||
image: mongo
|
||||
image: mongo:6
|
||||
restart: always
|
||||
networks:
|
||||
- internal
|
||||
@@ -65,19 +63,8 @@ services:
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
||||
environment:
|
||||
- BASE_URL=${BASE_URL}
|
||||
- MONGO_INITDB_ROOT_USERNAME=admin
|
||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
|
||||
- MONGO_USER=${MONGO_USER}
|
||||
- MONGO_PASS=${MONGO_PASSWORD}
|
||||
- MONGO_DB=${MONGO_DB}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
||||
- SURVEY_URL=${SURVEY_URL}
|
||||
- BUG_REPORT_URL=${BUG_REPORT_URL}
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
base:
|
||||
image: linuxodyssey/quest-base
|
||||
|
||||
@@ -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
|
||||
image: mongo:6
|
||||
restart: always
|
||||
networks:
|
||||
- internal
|
||||
ports:
|
||||
@@ -82,12 +12,17 @@ services:
|
||||
|
||||
base:
|
||||
image: linuxodyssey/quest-base
|
||||
networks:
|
||||
- players
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./quests/Dockerfile
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
networks:
|
||||
internal:
|
||||
players:
|
||||
name: linux-odyssey-players
|
||||
internal: true
|
||||
internal: false
|
||||
external: true
|
||||
@@ -29,4 +29,39 @@ down() {
|
||||
docker compose -f docker-compose.testing.yml down
|
||||
}
|
||||
|
||||
dump() {
|
||||
# Define service name and dump directory name
|
||||
SERVICE_NAME="db"
|
||||
COMPOSE_FILE="docker-compose.prod.yml"
|
||||
DUMP_NAME="mongodump-$(date --iso).archive"
|
||||
CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q $SERVICE_NAME)
|
||||
|
||||
# Fetch credentials from Docker environment variables
|
||||
MONGO_URL=$(docker compose -f $COMPOSE_FILE exec -T backend printenv MONGO_URL)
|
||||
echo mongo uri: $MONGO_URL
|
||||
|
||||
# Dump MongoDB data using credentials
|
||||
docker compose -f $COMPOSE_FILE exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME --uri=$MONGO_URL" &&
|
||||
|
||||
# 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."
|
||||
}
|
||||
$@
|
||||
@@ -0,0 +1,141 @@
|
||||
# Windows Environment Setup
|
||||
|
||||
## Install WSL and Ubuntu
|
||||
|
||||
Please refer to the Official [Setup WSL](https://learn.microsoft.com/en-us/windows/wsl/setup/environment) on Microsoft's website.
|
||||
|
||||
Run the following command in PowerShell as Administrator:
|
||||
|
||||
wsl --install
|
||||
|
||||
Then restart the computer. After restarting, you can open Ubuntu terminal by searching "Ubuntu" in Start menu. You will be prompted to create a new user account. Once you have done that, you can continue with the following steps.
|
||||
|
||||
## Setup Environment (In Ubuntu terminal)
|
||||
|
||||
1. Update and upgrade
|
||||
|
||||
```
|
||||
sudo apt-get update
|
||||
sudo apt-get upgrade
|
||||
```
|
||||
|
||||
1. Install Git
|
||||
|
||||
```
|
||||
sudo apt-get install git
|
||||
```
|
||||
|
||||
1. Install Node.js (Version 20.x)
|
||||
|
||||
```
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install nodejs
|
||||
sudo apt install build-essential
|
||||
sudo npm install -g yarn
|
||||
```
|
||||
|
||||
## Install Docker
|
||||
|
||||
(Refer to [get.docker.com](https://get.docker.com/))
|
||||
|
||||
To install the latest stable versions of Docker CLI, Docker Engine, and their
|
||||
dependencies:
|
||||
|
||||
1. Download the script
|
||||
|
||||
```
|
||||
curl -fsSL https://get.docker.com -o install-docker.sh
|
||||
```
|
||||
|
||||
1. verify the script's content
|
||||
|
||||
```
|
||||
cat install-docker.sh
|
||||
```
|
||||
|
||||
1. run the script with --dry-run to verify the steps it executes
|
||||
|
||||
```
|
||||
sh install-docker.sh --dry-run
|
||||
```
|
||||
|
||||
**NOTE: the script will recommend you to use Docker Desktop instead, but we need to use Docker inside WSL for the backend to work.** Just ignore it and wait for the installation to finish.
|
||||
|
||||
1. run the script either as root, or using sudo to perform the installation.
|
||||
|
||||
```
|
||||
sudo sh install-docker.sh
|
||||
```
|
||||
|
||||
1. Verify the installation
|
||||
|
||||
```
|
||||
docker --version
|
||||
```
|
||||
|
||||
1. Add your user to the docker group
|
||||
|
||||
```
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
**NOTE: You need to create a new WSL terminal to apply the changes.**
|
||||
|
||||
1. Verify the installation
|
||||
|
||||
```
|
||||
docker ps
|
||||
```
|
||||
This should return something like:
|
||||
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
|
||||
## Run the Project (In Ubuntu terminal)
|
||||
|
||||
1. Clone the repository
|
||||
|
||||
```
|
||||
git clone https://github.com/lancatlin/linux-odyssey.git
|
||||
cd linux-odyssey
|
||||
```
|
||||
|
||||
Optional: To open the project in VSCode, run the following command inside the project folder:
|
||||
|
||||
```
|
||||
code .
|
||||
```
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```
|
||||
yarn install
|
||||
```
|
||||
It is ok if there are some warnings and `gyp: ERR` errors, just verify there is `done` at the end.
|
||||
|
||||
1. Copy the environment variables
|
||||
|
||||
```
|
||||
cp .env.sample .env
|
||||
```
|
||||
|
||||
1. Docker setup
|
||||
|
||||
```
|
||||
docker compose pull
|
||||
docker compose build
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
<!-- Check folder ./config exist and is empty -->
|
||||
<!-- Check .env file exist -->
|
||||
|
||||
1. Run the project
|
||||
|
||||
```
|
||||
yarn build
|
||||
yarn dev
|
||||
```
|
||||
|
||||
<!-- Check ./config/ssh_key.pub is a file not a folder -->
|
||||
|
||||
Enjoy!
|
||||
@@ -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,34 @@
|
||||
"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"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
@@ -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,36 @@
|
||||
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.ADMIN_HOST || 'localhost',
|
||||
port: process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001,
|
||||
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
|
||||
username: getOrFailed('ADMIN_USERNAME'),
|
||||
password: getOrFailed('ADMIN_PASSWORD'),
|
||||
baseUrl:
|
||||
process.env.ADMIN_BASE_URL ||
|
||||
`http://${process.env.ADMIN_HOST || 'localhost'}:${
|
||||
process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001
|
||||
}`,
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||