Compare commits
No commits in common. "c6ec335b04ed8c7a162295cf5ddcbc162e0d93d1" and "6847b6605c0e17cfe5d80e0ff10e9333d77f144d" have entirely different histories.
c6ec335b04
...
6847b6605c
38 changed files with 544 additions and 1396 deletions
12
.env.example
12
.env.example
|
@ -1,16 +1,4 @@
|
||||||
# Spotify API
|
|
||||||
CLIENT_ID=<spotifyAPIID>
|
CLIENT_ID=<spotifyAPIID>
|
||||||
CLIENT_SECRET=<spotifyAPISecret>
|
CLIENT_SECRET=<spotifyAPISecret>
|
||||||
|
|
||||||
# Session token salt
|
|
||||||
SESH_SECRET=<longRandomString>
|
SESH_SECRET=<longRandomString>
|
||||||
|
|
||||||
# Secret used to generate ALTCHA captchas
|
|
||||||
ALTCHA_HMAC=<longRandomString>
|
|
||||||
|
|
||||||
# SQLite DB location
|
|
||||||
DATABASE_URL=local.db
|
DATABASE_URL=local.db
|
||||||
|
|
||||||
# Rate limit Redis instance on upstash
|
|
||||||
UPSTASH_REDIS_URL=""
|
|
||||||
UPSTASH_REDIS_TOKEN=""
|
|
||||||
|
|
|
@ -57,14 +57,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/kode-mono": "^5.1.1",
|
"@fontsource-variable/kode-mono": "^5.1.1",
|
||||||
"@fontsource-variable/smooch-sans": "^5.1.1",
|
"@fontsource-variable/smooch-sans": "^5.1.1",
|
||||||
"@upstash/ratelimit": "^2.0.5",
|
|
||||||
"@upstash/redis": "^1.34.4",
|
|
||||||
"altcha": "^1.1.1",
|
|
||||||
"altcha-lib": "^1.2.0",
|
|
||||||
"better-sqlite3": "^11.8.0",
|
"better-sqlite3": "^11.8.0",
|
||||||
"drizzle-orm": "^0.38.4",
|
"drizzle-orm": "^0.38.4",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"runed": "^0.23.3",
|
|
||||||
"spotify-web-api-node": "^5.0.2",
|
"spotify-web-api-node": "^5.0.2",
|
||||||
"svelte-kit-sessions": "^0.4.0"
|
"svelte-kit-sessions": "^0.4.0"
|
||||||
},
|
},
|
||||||
|
|
82
pnpm-lock.yaml
generated
82
pnpm-lock.yaml
generated
|
@ -14,18 +14,6 @@ importers:
|
||||||
'@fontsource-variable/smooch-sans':
|
'@fontsource-variable/smooch-sans':
|
||||||
specifier: ^5.1.1
|
specifier: ^5.1.1
|
||||||
version: 5.1.1
|
version: 5.1.1
|
||||||
'@upstash/ratelimit':
|
|
||||||
specifier: ^2.0.5
|
|
||||||
version: 2.0.5(@upstash/redis@1.34.4)
|
|
||||||
'@upstash/redis':
|
|
||||||
specifier: ^1.34.4
|
|
||||||
version: 1.34.4
|
|
||||||
altcha:
|
|
||||||
specifier: ^1.1.1
|
|
||||||
version: 1.1.1
|
|
||||||
altcha-lib:
|
|
||||||
specifier: ^1.2.0
|
|
||||||
version: 1.2.0
|
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: ^11.8.0
|
specifier: ^11.8.0
|
||||||
version: 11.8.1
|
version: 11.8.1
|
||||||
|
@ -35,9 +23,6 @@ importers:
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.0.9
|
specifier: ^5.0.9
|
||||||
version: 5.0.9
|
version: 5.0.9
|
||||||
runed:
|
|
||||||
specifier: ^0.23.3
|
|
||||||
version: 0.23.3(svelte@5.19.1)
|
|
||||||
spotify-web-api-node:
|
spotify-web-api-node:
|
||||||
specifier: ^5.0.2
|
specifier: ^5.0.2
|
||||||
version: 5.0.2
|
version: 5.0.2
|
||||||
|
@ -163,9 +148,6 @@ packages:
|
||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
'@altcha/crypto@0.0.1':
|
|
||||||
resolution: {integrity: sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==}
|
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
@ -968,11 +950,6 @@ packages:
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.18.0':
|
|
||||||
resolution: {integrity: sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.31.0':
|
'@rollup/rollup-linux-x64-gnu@4.31.0':
|
||||||
resolution: {integrity: sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==}
|
resolution: {integrity: sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
|
@ -1148,18 +1125,6 @@ packages:
|
||||||
resolution: {integrity: sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==}
|
resolution: {integrity: sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@upstash/core-analytics@0.0.10':
|
|
||||||
resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==}
|
|
||||||
engines: {node: '>=16.0.0'}
|
|
||||||
|
|
||||||
'@upstash/ratelimit@2.0.5':
|
|
||||||
resolution: {integrity: sha512-1FRv0cs3ZlBjCNOCpCmKYmt9BYGIJf0J0R3pucOPE88R21rL7jNjXG+I+rN/BVOvYJhI9niRAS/JaSNjiSICxA==}
|
|
||||||
peerDependencies:
|
|
||||||
'@upstash/redis': ^1.34.3
|
|
||||||
|
|
||||||
'@upstash/redis@1.34.4':
|
|
||||||
resolution: {integrity: sha512-AZx2iD5s1Pu/KCrRA7KVCffu3NSoaYnNY7N9YI7aLAYhcJfsriQKTe+8OxQWJqGqFbrvm17Lyr9HFnDLvqNpfA==}
|
|
||||||
|
|
||||||
'@vinejs/compiler@3.0.0':
|
'@vinejs/compiler@3.0.0':
|
||||||
resolution: {integrity: sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==}
|
resolution: {integrity: sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
@ -1186,12 +1151,6 @@ packages:
|
||||||
ajv@6.12.6:
|
ajv@6.12.6:
|
||||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||||
|
|
||||||
altcha-lib@1.2.0:
|
|
||||||
resolution: {integrity: sha512-S5WF8QLNRaM1hvK24XPhOLfu9is2EBCvH7+nv50sM5CaIdUCqQCd0WV/qm/ZZFGTdSoKLuDp+IapZxBLvC+SNg==}
|
|
||||||
|
|
||||||
altcha@1.1.1:
|
|
||||||
resolution: {integrity: sha512-BPqLHiCAcVuF+dwshPyVBtpAXvgcSOO5DA3KfMLfQO3I5gxytE2bUrclYK5E8vWAzzrkiiz6OhFrZ69nUAOOzg==}
|
|
||||||
|
|
||||||
ansi-regex@5.0.1:
|
ansi-regex@5.0.1:
|
||||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
@ -1375,9 +1334,6 @@ packages:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
crypto-js@4.2.0:
|
|
||||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
@ -2346,8 +2302,8 @@ packages:
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
runed@0.23.3:
|
runed@0.23.2:
|
||||||
resolution: {integrity: sha512-qmL6JOvI9fg2XrSI9eP8bVIaAyk1ztVZsoj37hTs4BSuOOyeLkrIPI16mwarXFYbxSfyJGCwAWgfpSq+ehQmgg==}
|
resolution: {integrity: sha512-AhHCb5/B+YQW6ar1pzhGQOQy+byfjCH63ofuhrexSWwQKhC0EbQ60Z/wMYwETLo3ZubhwlNryxBt0seOMOrVFQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.7.0
|
svelte: ^5.7.0
|
||||||
|
|
||||||
|
@ -2768,8 +2724,6 @@ snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
'@altcha/crypto@0.0.1': {}
|
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.8
|
'@jridgewell/gen-mapping': 0.3.8
|
||||||
|
@ -3314,9 +3268,6 @@ snapshots:
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.31.0':
|
'@rollup/rollup-linux-s390x-gnu@4.31.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.18.0':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.31.0':
|
'@rollup/rollup-linux-x64-gnu@4.31.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
@ -3534,19 +3485,6 @@ snapshots:
|
||||||
'@typescript-eslint/types': 8.22.0
|
'@typescript-eslint/types': 8.22.0
|
||||||
eslint-visitor-keys: 4.2.0
|
eslint-visitor-keys: 4.2.0
|
||||||
|
|
||||||
'@upstash/core-analytics@0.0.10':
|
|
||||||
dependencies:
|
|
||||||
'@upstash/redis': 1.34.4
|
|
||||||
|
|
||||||
'@upstash/ratelimit@2.0.5(@upstash/redis@1.34.4)':
|
|
||||||
dependencies:
|
|
||||||
'@upstash/core-analytics': 0.0.10
|
|
||||||
'@upstash/redis': 1.34.4
|
|
||||||
|
|
||||||
'@upstash/redis@1.34.4':
|
|
||||||
dependencies:
|
|
||||||
crypto-js: 4.2.0
|
|
||||||
|
|
||||||
'@vinejs/compiler@3.0.0':
|
'@vinejs/compiler@3.0.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
@ -3579,14 +3517,6 @@ snapshots:
|
||||||
json-schema-traverse: 0.4.1
|
json-schema-traverse: 0.4.1
|
||||||
uri-js: 4.4.1
|
uri-js: 4.4.1
|
||||||
|
|
||||||
altcha-lib@1.2.0: {}
|
|
||||||
|
|
||||||
altcha@1.1.1:
|
|
||||||
dependencies:
|
|
||||||
'@altcha/crypto': 0.0.1
|
|
||||||
optionalDependencies:
|
|
||||||
'@rollup/rollup-linux-x64-gnu': 4.18.0
|
|
||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
ansi-regex@6.1.0: {}
|
ansi-regex@6.1.0: {}
|
||||||
|
@ -3651,7 +3581,7 @@ snapshots:
|
||||||
'@floating-ui/dom': 1.6.13
|
'@floating-ui/dom': 1.6.13
|
||||||
'@internationalized/date': 3.7.0
|
'@internationalized/date': 3.7.0
|
||||||
esm-env: 1.2.2
|
esm-env: 1.2.2
|
||||||
runed: 0.23.3(svelte@5.19.1)
|
runed: 0.23.2(svelte@5.19.1)
|
||||||
svelte: 5.19.1
|
svelte: 5.19.1
|
||||||
svelte-toolbelt: 0.7.1(svelte@5.19.1)
|
svelte-toolbelt: 0.7.1(svelte@5.19.1)
|
||||||
tabbable: 6.2.0
|
tabbable: 6.2.0
|
||||||
|
@ -3778,8 +3708,6 @@ snapshots:
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
crypto-js@4.2.0: {}
|
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
dayjs@1.11.13:
|
dayjs@1.11.13:
|
||||||
|
@ -4628,7 +4556,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
|
|
||||||
runed@0.23.3(svelte@5.19.1):
|
runed@0.23.2(svelte@5.19.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esm-env: 1.2.2
|
esm-env: 1.2.2
|
||||||
svelte: 5.19.1
|
svelte: 5.19.1
|
||||||
|
@ -4854,7 +4782,7 @@ snapshots:
|
||||||
svelte-toolbelt@0.7.1(svelte@5.19.1):
|
svelte-toolbelt@0.7.1(svelte@5.19.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
runed: 0.23.3(svelte@5.19.1)
|
runed: 0.23.2(svelte@5.19.1)
|
||||||
style-to-object: 1.0.8
|
style-to-object: 1.0.8
|
||||||
svelte: 5.19.1
|
svelte: 5.19.1
|
||||||
|
|
||||||
|
|
19
src/app.css
19
src/app.css
|
@ -32,16 +32,6 @@
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 220 13% 91%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
|
||||||
--altcha-border-width: 2px;
|
|
||||||
--altcha-border-radius: var(--radius);
|
|
||||||
--altcha-color-base: #ffffff;
|
|
||||||
--altcha-color-border: #f5f5f4;
|
|
||||||
--altcha-color-text: #0c0a09;
|
|
||||||
--altcha-color-border-focus: #78716c;
|
|
||||||
--altcha-color-error-text: #f23939;
|
|
||||||
--altcha-color-footer-bg: #f5f5f4;
|
|
||||||
--altcha-max-width: 260px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
@ -61,7 +51,7 @@
|
||||||
--secondary-foreground: 60 9.1% 94%;
|
--secondary-foreground: 60 9.1% 94%;
|
||||||
--accent: 12 6.5% 15.1%;
|
--accent: 12 6.5% 15.1%;
|
||||||
--accent-foreground: 60 9.1% 94%;
|
--accent-foreground: 60 9.1% 94%;
|
||||||
--destructive: 0 70% 63.9%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 60 9.1% 94%;
|
--destructive-foreground: 60 9.1% 94%;
|
||||||
--ring: 24 5.7% 82.9%;
|
--ring: 24 5.7% 82.9%;
|
||||||
--sidebar-background: 240 5.9% 10%;
|
--sidebar-background: 240 5.9% 10%;
|
||||||
|
@ -72,13 +62,6 @@
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
|
||||||
--altcha-color-base: #0c0a09;
|
|
||||||
--altcha-color-text: #f1f1ee;
|
|
||||||
--altcha-color-border: #292524;
|
|
||||||
--altcha-color-border-focus: #a8a29e;
|
|
||||||
--altcha-color-error-text: #f23939;
|
|
||||||
--altcha-color-footer-bg: #292524;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import Input from './ui/input/input.svelte';
|
|
||||||
// Importing altcha package will introduce a new element <altcha-widget>
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await import('altcha');
|
|
||||||
});
|
|
||||||
|
|
||||||
let { value = $bindable(), ...props } = $props();
|
|
||||||
|
|
||||||
const estonianStrings = {
|
|
||||||
ariaLinkLabel: 'Külasta Altcha.org',
|
|
||||||
error: 'Kinnitus nurjus. Proovi hiljem uuesti.',
|
|
||||||
expired: 'Kinnitus aegus. Proovi uuesti.',
|
|
||||||
footer:
|
|
||||||
'Turvab <a href="https://altcha.org/" target="_blank" aria-label="Külasta Altcha.org">ALTCHA</a>',
|
|
||||||
label: 'Ma ei ole robot',
|
|
||||||
verified: 'Kõik ok!',
|
|
||||||
verifying: 'Kinntan...',
|
|
||||||
waitAlert: 'Kinnitan... palun oota.'
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Input type="hidden" bind:value {...props} />
|
|
||||||
|
|
||||||
<!-- Configure your `challengeurl` and remove the `test` attribute, see docs: https://altcha.org/docs/website-integration/#using-altcha-widget -->
|
|
||||||
<altcha-widget
|
|
||||||
strings={JSON.stringify(estonianStrings)}
|
|
||||||
debug
|
|
||||||
challengeurl="/api/altcha"
|
|
||||||
spamfilter
|
|
||||||
blockspam
|
|
||||||
hidefooter
|
|
||||||
expire={180000}
|
|
||||||
onverified={(ev) => {
|
|
||||||
if (ev.detail.payload) {
|
|
||||||
value = ev.detail.payload;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></altcha-widget>
|
|
|
@ -15,7 +15,7 @@
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
|
||||||
import { truncate } from '$lib/utils';
|
import { truncate } from '$lib/utils';
|
||||||
import { getAlbumClientState } from '$lib/client/pakubiiti/AlbumClientState.svelte';
|
import { getAlbumClientState } from '$lib/client/AlbumClientState.svelte';
|
||||||
|
|
||||||
let { items, image = false, type = 'default' } = $props();
|
let { items, image = false, type = 'default' } = $props();
|
||||||
|
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
|
||||||
import Content from "./tabs-content.svelte";
|
|
||||||
import List from "./tabs-list.svelte";
|
|
||||||
import Trigger from "./tabs-trigger.svelte";
|
|
||||||
|
|
||||||
const Root = TabsPrimitive.Root;
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
Content,
|
|
||||||
List,
|
|
||||||
Trigger,
|
|
||||||
//
|
|
||||||
Root as Tabs,
|
|
||||||
Content as TabsContent,
|
|
||||||
List as TabsList,
|
|
||||||
Trigger as TabsTrigger,
|
|
||||||
};
|
|
|
@ -1,21 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
|
||||||
import { cn } from "$lib/utils.js";
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
value,
|
|
||||||
...restProps
|
|
||||||
}: TabsPrimitive.ContentProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
bind:ref
|
|
||||||
class={cn(
|
|
||||||
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{value}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
|
@ -1,19 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
|
||||||
import { cn } from "$lib/utils.js";
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
...restProps
|
|
||||||
}: TabsPrimitive.ListProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TabsPrimitive.List
|
|
||||||
bind:ref
|
|
||||||
class={cn(
|
|
||||||
"bg-muted text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
|
@ -1,21 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
|
||||||
import { cn } from "$lib/utils.js";
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
value,
|
|
||||||
...restProps
|
|
||||||
}: TabsPrimitive.TriggerProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
bind:ref
|
|
||||||
class={cn(
|
|
||||||
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{value}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
|
@ -1,28 +0,0 @@
|
||||||
import Root from "./textarea.svelte";
|
|
||||||
|
|
||||||
type FormTextareaEvent<T extends Event = Event> = T & {
|
|
||||||
currentTarget: EventTarget & HTMLTextAreaElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TextareaEvents = {
|
|
||||||
blur: FormTextareaEvent<FocusEvent>;
|
|
||||||
change: FormTextareaEvent<Event>;
|
|
||||||
click: FormTextareaEvent<MouseEvent>;
|
|
||||||
focus: FormTextareaEvent<FocusEvent>;
|
|
||||||
keydown: FormTextareaEvent<KeyboardEvent>;
|
|
||||||
keypress: FormTextareaEvent<KeyboardEvent>;
|
|
||||||
keyup: FormTextareaEvent<KeyboardEvent>;
|
|
||||||
mouseover: FormTextareaEvent<MouseEvent>;
|
|
||||||
mouseenter: FormTextareaEvent<MouseEvent>;
|
|
||||||
mouseleave: FormTextareaEvent<MouseEvent>;
|
|
||||||
paste: FormTextareaEvent<ClipboardEvent>;
|
|
||||||
input: FormTextareaEvent<InputEvent>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
//
|
|
||||||
Root as Textarea,
|
|
||||||
type TextareaEvents,
|
|
||||||
type FormTextareaEvent,
|
|
||||||
};
|
|
|
@ -1,22 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { WithElementRef, WithoutChildren } from "bits-ui";
|
|
||||||
import type { HTMLTextareaAttributes } from "svelte/elements";
|
|
||||||
import { cn } from "$lib/utils.js";
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
value = $bindable(),
|
|
||||||
class: className,
|
|
||||||
...restProps
|
|
||||||
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
bind:this={ref}
|
|
||||||
bind:value
|
|
||||||
class={cn(
|
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
|
||||||
></textarea>
|
|
|
@ -1,11 +1,6 @@
|
||||||
import type { GamesObj } from '$lib/types';
|
import type { GamesObj } from '$lib/types';
|
||||||
|
|
||||||
const games: GamesObj = {
|
const games: GamesObj = {
|
||||||
rahvatarkus: {
|
|
||||||
name: 'Rahva tarkus',
|
|
||||||
image: '',
|
|
||||||
description: 'Rahvas teab, sest pitu pead on mitu pead - hajusintelligents'
|
|
||||||
},
|
|
||||||
vaukuivali: {
|
vaukuivali: {
|
||||||
name: 'Vau kui vali',
|
name: 'Vau kui vali',
|
||||||
image: '',
|
image: '',
|
||||||
|
@ -20,6 +15,11 @@ const games: GamesObj = {
|
||||||
name: 'Paku biiti',
|
name: 'Paku biiti',
|
||||||
image: '',
|
image: '',
|
||||||
description: 'Sorteeri kolme suvalise muusika albumi pealkiri, artistid ja pilt.'
|
description: 'Sorteeri kolme suvalise muusika albumi pealkiri, artistid ja pilt.'
|
||||||
|
},
|
||||||
|
'': {
|
||||||
|
name: 'Rohkem mänge soon™',
|
||||||
|
image: '',
|
||||||
|
description: ''
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ImageCreditType, type Project } from '$lib/types';
|
import type { Project } from '$lib/types';
|
||||||
import badges from './badges';
|
import badges from './badges';
|
||||||
|
|
||||||
import skpImg from '$lib/assets/skp.jpg?enhanced';
|
import skpImg from '$lib/assets/skp.jpg?enhanced';
|
||||||
|
@ -12,7 +12,7 @@ const projects: Project[] = [
|
||||||
image: {
|
image: {
|
||||||
src: skpImg,
|
src: skpImg,
|
||||||
credit: {
|
credit: {
|
||||||
type: ImageCreditType.instagram,
|
type: 'instagram',
|
||||||
author: 'Mimmu',
|
author: 'Mimmu',
|
||||||
href: 'https://www.instagram.com/musamimmu/'
|
href: 'https://www.instagram.com/musamimmu/'
|
||||||
},
|
},
|
||||||
|
@ -27,7 +27,7 @@ const projects: Project[] = [
|
||||||
image: {
|
image: {
|
||||||
src: dysasterImg,
|
src: dysasterImg,
|
||||||
credit: {
|
credit: {
|
||||||
type: ImageCreditType.instagram,
|
type: 'instagram',
|
||||||
author: 'Mattias Mägi',
|
author: 'Mattias Mägi',
|
||||||
href: 'https://www.instagram.com/mattias.mix/'
|
href: 'https://www.instagram.com/mattias.mix/'
|
||||||
},
|
},
|
||||||
|
@ -42,7 +42,7 @@ const projects: Project[] = [
|
||||||
image: {
|
image: {
|
||||||
src: monospaceeImg,
|
src: monospaceeImg,
|
||||||
credit: {
|
credit: {
|
||||||
type: ImageCreditType.instagram,
|
type: 'instagram',
|
||||||
author: 'Liisa Jõhvik',
|
author: 'Liisa Jõhvik',
|
||||||
href: 'https://www.instagram.com/liisajohvik.photo/'
|
href: 'https://www.instagram.com/liisajohvik.photo/'
|
||||||
},
|
},
|
||||||
|
@ -67,7 +67,7 @@ const projects: Project[] = [
|
||||||
image: {
|
image: {
|
||||||
src: '/assets/hakkerikoda.svg',
|
src: '/assets/hakkerikoda.svg',
|
||||||
credit: {
|
credit: {
|
||||||
type: ImageCreditType.web,
|
type: 'web',
|
||||||
author: 'treierxyz',
|
author: 'treierxyz',
|
||||||
href: 'https://treier.xyz'
|
href: 'https://treier.xyz'
|
||||||
},
|
},
|
||||||
|
|
45
src/lib/server/pakubiiti/AlbumState.svelte.ts
Normal file
45
src/lib/server/pakubiiti/AlbumState.svelte.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import type { AlbumSolveState } from '$lib/types';
|
||||||
|
|
||||||
|
class AlbumState {
|
||||||
|
private albums: AlbumSolveState[] | undefined = undefined;
|
||||||
|
|
||||||
|
setAlbums(data: AlbumSolveState[]) {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.albums = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSolve(data: AlbumSolveState[]) {
|
||||||
|
if (!data || !this.albums) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const solve of data) {
|
||||||
|
const search = this.albums.filter((album) => album.name === solve.name);
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matching = search.at(0);
|
||||||
|
|
||||||
|
if (!matching) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matching.image !== solve.image) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matching.artists !== solve.artists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const albumState = new AlbumState();
|
|
@ -1,140 +1,91 @@
|
||||||
import type { AlbumSolveState, Player } from '$lib/types';
|
import type { Player } from '$lib/types';
|
||||||
|
|
||||||
export class PlayerState {
|
export class PlayerState {
|
||||||
players = $state<Player[]>([]);
|
players = $state<Player[]>([]);
|
||||||
private lastAccessed: Map<string, number> = new Map();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Clean up expired sessions every hour
|
|
||||||
setInterval(() => this.cleanup(), 1000 * 60 * 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateLastAccessed(id: string) {
|
|
||||||
this.lastAccessed.set(id, Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
private findPlayer(id: string): Player | undefined {
|
|
||||||
this.updateLastAccessed(id);
|
|
||||||
return this.players.find((player) => player.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkSolution(solution: AlbumSolveState[], submission: AlbumSolveState[]) {
|
|
||||||
if (!solution || !submission) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const solve of solution) {
|
|
||||||
const search = submission.filter((album) => album.name === solve.name);
|
|
||||||
|
|
||||||
if (!search) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matching = search.at(0);
|
|
||||||
|
|
||||||
if (!matching) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matching.image !== solve.image) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matching.artists !== solve.artists) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
newPlayer(id: string) {
|
newPlayer(id: string) {
|
||||||
this.updateLastAccessed(id);
|
this.players.push({ id: id, stage: 0, highscore: 0, playing: true });
|
||||||
this.players.push({ id: id, stage: 0, highscore: 0, playing: true, albums: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
nextStage(id: string) {
|
|
||||||
const player = this.findPlayer(id);
|
|
||||||
if (player) {
|
|
||||||
player.stage += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
restart(id: string) {
|
|
||||||
const player = this.findPlayer(id);
|
|
||||||
if (player) {
|
|
||||||
player.stage = 0;
|
|
||||||
player.playing = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
score(id: string, submission: AlbumSolveState[]) {
|
|
||||||
if (!submission) return false;
|
|
||||||
|
|
||||||
const player = this.findPlayer(id);
|
|
||||||
if (!player) return false;
|
|
||||||
|
|
||||||
if (this.checkSolution(player.albums, submission)) {
|
|
||||||
player.stage += 1;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
player.playing = false;
|
|
||||||
if (player.stage > player.highscore) {
|
|
||||||
player.highscore = player.stage;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
getHighscore(id: string) {
|
|
||||||
const player = this.findPlayer(id);
|
|
||||||
return player?.highscore;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getStage(id: string) {
|
getStage(id: string) {
|
||||||
const player = this.findPlayer(id);
|
const player = this.players.find((player) => player.id === id);
|
||||||
return player?.stage;
|
|
||||||
|
if (!player) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return player.stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextStage(id: string) {
|
||||||
|
const player = this.players.find((player) => player.id === id);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.stage += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(id: string) {
|
||||||
|
const player = this.players.find((player) => player.id === id);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.stage = 0;
|
||||||
|
player.playing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
score(id: string, won: boolean) {
|
||||||
|
const player = this.players.find((player) => player.id === id);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (won) {
|
||||||
|
player.stage += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.playing = false;
|
||||||
|
|
||||||
|
if (player.stage > player.highscore) {
|
||||||
|
player.highscore = player.stage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getHighscore(id: string) {
|
||||||
|
const player = this.players.find((player) => player.id === id);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return player.highscore;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlaying(id: string) {
|
getPlaying(id: string) {
|
||||||
const player = this.findPlayer(id);
|
const player = this.players.find((player) => player.id === id);
|
||||||
return player?.playing;
|
|
||||||
|
if (!player) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return player.playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPlaying(id: string, playing: boolean) {
|
setPlaying(id: string, playing: boolean) {
|
||||||
const player = this.findPlayer(id);
|
const player = this.players.find((player) => player.id === id);
|
||||||
if (player) {
|
|
||||||
|
if (!player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
player.playing = playing;
|
player.playing = playing;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
getAlbums(id: string) {
|
|
||||||
const player = this.findPlayer(id);
|
|
||||||
return player?.albums;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAlbums(id: string, albums: AlbumSolveState[]) {
|
|
||||||
const player = this.findPlayer(id);
|
|
||||||
if (player) {
|
|
||||||
player.albums = albums;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up players that haven't been accessed in 24 hours
|
|
||||||
private cleanup(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
const expiryTime = 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
|
|
||||||
this.players = this.players.filter((player) => {
|
|
||||||
const lastAccess = this.lastAccessed.get(player.id);
|
|
||||||
if (!lastAccess || now - lastAccess > expiryTime) {
|
|
||||||
this.lastAccessed.delete(player.id);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const playerState = new PlayerState();
|
export const playerState = new PlayerState();
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
import { dev } from '$app/environment';
|
|
||||||
import getPoolSize from './getPoolSize';
|
|
||||||
|
|
||||||
// Based on code generated by Claude 3.5 Sonnet
|
|
||||||
class QuestionBalanceStore {
|
|
||||||
private balances: Map<string, number>;
|
|
||||||
private lastAccessed: Map<string, number>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.balances = new Map();
|
|
||||||
this.lastAccessed = new Map();
|
|
||||||
|
|
||||||
// Clean up expired sessions every hour
|
|
||||||
setInterval(() => this.cleanup(), 1000 * 60 * 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get remaining questions for a session
|
|
||||||
getBalance(sessionToken: string): number {
|
|
||||||
this.lastAccessed.set(sessionToken, Date.now());
|
|
||||||
return this.balances.get(sessionToken) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add questions to a session (e.g., after answering)
|
|
||||||
addQuestions(sessionToken: string, count: number = 1): void {
|
|
||||||
this.lastAccessed.set(sessionToken, Date.now());
|
|
||||||
|
|
||||||
const currentBalance = this.balances.get(sessionToken) || 0;
|
|
||||||
this.balances.set(sessionToken, currentBalance + count);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a question from the balance
|
|
||||||
async useQuestion(sessionToken: string): Promise<boolean> {
|
|
||||||
this.lastAccessed.set(sessionToken, Date.now());
|
|
||||||
|
|
||||||
const currentBalance = this.balances.get(sessionToken) || 0;
|
|
||||||
|
|
||||||
// New users can neither ask nor answer in this case
|
|
||||||
// Allow one question for this edge case
|
|
||||||
if (currentBalance === 0) {
|
|
||||||
const poolSize = await getPoolSize();
|
|
||||||
return poolSize === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.balances.set(sessionToken, currentBalance - 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up sessions that haven't been accessed in 24 hours
|
|
||||||
private cleanup(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
const expiryTime = 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
|
|
||||||
for (const [token, lastAccess] of this.lastAccessed.entries()) {
|
|
||||||
if (now - lastAccess > expiryTime) {
|
|
||||||
this.balances.delete(token);
|
|
||||||
this.lastAccessed.delete(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For debugging in development
|
|
||||||
debugInfo(): unknown {
|
|
||||||
if (!dev) return null;
|
|
||||||
return {
|
|
||||||
totalSessions: this.balances.size,
|
|
||||||
sessions: Object.fromEntries(this.balances),
|
|
||||||
lastAccessed: Object.fromEntries(this.lastAccessed)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create singleton instance
|
|
||||||
export const questionBalanceStore = new QuestionBalanceStore();
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { db } from '$lib/server/db';
|
|
||||||
import { questions } from '$lib/server/db/schema';
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
const results = await db
|
|
||||||
.select({
|
|
||||||
poolSize: sql`COUNT(CASE WHEN answer_count < 5 THEN 1 END)`
|
|
||||||
})
|
|
||||||
.from(questions);
|
|
||||||
|
|
||||||
return Number(results[0].poolSize);
|
|
||||||
};
|
|
|
@ -1,58 +0,0 @@
|
||||||
import { Redis } from '@upstash/redis';
|
|
||||||
import { Ratelimit } from '@upstash/ratelimit';
|
|
||||||
import { UPSTASH_REDIS_TOKEN, UPSTASH_REDIS_URL } from '$env/static/private';
|
|
||||||
|
|
||||||
const redis = new Redis({
|
|
||||||
url: UPSTASH_REDIS_URL,
|
|
||||||
token: UPSTASH_REDIS_TOKEN
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ratelimit = {
|
|
||||||
noSesh: new Ratelimit({
|
|
||||||
redis,
|
|
||||||
prefix: 'ratelimit:noSesh',
|
|
||||||
limiter: Ratelimit.slidingWindow(1, '1m'),
|
|
||||||
enableProtection: true
|
|
||||||
}),
|
|
||||||
pakubiiti: new Ratelimit({
|
|
||||||
redis,
|
|
||||||
prefix: 'ratelimit:pakubiiti',
|
|
||||||
limiter: Ratelimit.slidingWindow(5, '15s'),
|
|
||||||
enableProtection: true
|
|
||||||
}),
|
|
||||||
pakubiitiIP: new Ratelimit({
|
|
||||||
redis,
|
|
||||||
prefix: 'ratelimit:pakubiitiIP',
|
|
||||||
limiter: Ratelimit.slidingWindow(5, '15s'),
|
|
||||||
enableProtection: true
|
|
||||||
}),
|
|
||||||
rahvaAnswer: new Ratelimit({
|
|
||||||
redis,
|
|
||||||
prefix: 'ratelimit:rahvaAnswer',
|
|
||||||
limiter: Ratelimit.slidingWindow(8, '15s'),
|
|
||||||
enableProtection: true
|
|
||||||
}),
|
|
||||||
rahvaAnswerIP: new Ratelimit({
|
|
||||||
redis,
|
|
||||||
prefix: 'ratelimit:rahvaAnswerIP',
|
|
||||||
limiter: Ratelimit.slidingWindow(8, '15s'),
|
|
||||||
enableProtection: true
|
|
||||||
}),
|
|
||||||
rahvaQuestion: new Ratelimit({
|
|
||||||
redis,
|
|
||||||
prefix: 'ratelimit:rahvaQuestion',
|
|
||||||
limiter: Ratelimit.slidingWindow(8, '15s'),
|
|
||||||
enableProtection: true
|
|
||||||
}),
|
|
||||||
rahvaQuestionIP: new Ratelimit({
|
|
||||||
redis,
|
|
||||||
prefix: 'ratelimit:rahvaQuestionIP',
|
|
||||||
limiter: Ratelimit.slidingWindow(8, '15s'),
|
|
||||||
enableProtection: true
|
|
||||||
}),
|
|
||||||
rahvaQuestionAPI: new Ratelimit({
|
|
||||||
redis,
|
|
||||||
prefix: 'ratelimit:rahvaQuestionAPI',
|
|
||||||
limiter: Ratelimit.slidingWindow(15, '15s')
|
|
||||||
})
|
|
||||||
};
|
|
|
@ -41,7 +41,6 @@ export type Player = {
|
||||||
stage: number;
|
stage: number;
|
||||||
highscore: number;
|
highscore: number;
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
albums: AlbumSolveState[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TimeRemaining {
|
export interface TimeRemaining {
|
||||||
|
@ -80,9 +79,3 @@ export type TagsObj = Record<string, Tag>;
|
||||||
|
|
||||||
export type Answer = typeof answers.$inferSelect;
|
export type Answer = typeof answers.$inferSelect;
|
||||||
export type Question = typeof questions.$inferSelect & { answers: Answer[] };
|
export type Question = typeof questions.$inferSelect & { answers: Answer[] };
|
||||||
|
|
||||||
export interface SoundCheckpoint {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
image: EnhancedImage | undefined;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
import { ALTCHA_HMAC } from '$env/static/private';
|
|
||||||
import { json } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import { createChallenge, verifySolution } from 'altcha-lib';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const challenge = await createChallenge({
|
|
||||||
hmacKey: ALTCHA_HMAC,
|
|
||||||
maxNumber: 100000 // the maximum random number
|
|
||||||
});
|
|
||||||
console.log('challange get');
|
|
||||||
|
|
||||||
return json(challenge);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST({ request }) {
|
|
||||||
const { payload }: { payload: string } = await request.json();
|
|
||||||
console.log('challange done');
|
|
||||||
const ok = await verifySolution(payload, ALTCHA_HMAC);
|
|
||||||
|
|
||||||
return json({ ok });
|
|
||||||
}
|
|
|
@ -1,6 +1,7 @@
|
||||||
import type { AlbumSolveState } from '$lib/types';
|
import type { AlbumSolveState } from '$lib/types';
|
||||||
|
|
||||||
import { error, json } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { albumState } from '$lib/server/pakubiiti/AlbumState.svelte';
|
||||||
import { spotifyAPI } from '$lib/server/pakubiiti/Spotify.svelte';
|
import { spotifyAPI } from '$lib/server/pakubiiti/Spotify.svelte';
|
||||||
|
|
||||||
const maxTries = 10;
|
const maxTries = 10;
|
||||||
|
@ -29,8 +30,12 @@ export async function GET({ params }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (albums.length !== count) {
|
if (albums.length !== count) {
|
||||||
|
albumState.setAlbums([]);
|
||||||
|
|
||||||
return error(500, "Couldn't get albums from Spotify.");
|
return error(500, "Couldn't get albums from Spotify.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
albumState.setAlbums(albums);
|
||||||
|
|
||||||
return json({ albums: albums });
|
return json({ albums: albums });
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
import { eq, sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { questions, answers } from '$lib/server/db/schema';
|
import { questions, answers } from '$lib/server/db/schema';
|
||||||
import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
import { ratelimit } from '$lib/server/redis';
|
|
||||||
|
|
||||||
const maxAnswers = 5;
|
const maxAnswers = 5;
|
||||||
|
|
||||||
|
@ -20,15 +16,6 @@ export async function POST({ locals, request }) {
|
||||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { success, reset } = await ratelimit.rahvaAnswer.limit(user);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
const timeRemaining = Math.floor((reset - Date.now()) / 1000);
|
|
||||||
const message = `Võta veits rahulikumalt. Proovi ${timeRemaining}s pärast uuesti.`;
|
|
||||||
|
|
||||||
return json({ error: message }, { status: 429 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start transaction
|
// Start transaction
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(async (tx) => {
|
||||||
// Get question and validate in one query
|
// Get question and validate in one query
|
||||||
|
@ -42,17 +29,17 @@ export async function POST({ locals, request }) {
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!question.length) {
|
if (!question.length) {
|
||||||
return json({ error: 'Seda küsimust ei leitud' }, { status: 400 });
|
return json({ error: 'Question not found' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [questionData] = question;
|
const [questionData] = question;
|
||||||
|
|
||||||
if (questionData.creator === user) {
|
// if (questionData.creator === user) {
|
||||||
return json({ error: 'Iseendale vastamine ei ole produktiivne' }, { status: 400 });
|
// return json({ error: 'Cannot answer own question' }, { status: 400 });
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (questionData.answerCount >= maxAnswers) {
|
if (questionData.answerCount >= maxAnswers) {
|
||||||
return json({ error: 'Sellel küsimusel on juba piisavalt küsimusi' }, { status: 400 });
|
return json({ error: 'No more answers needed' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert answer and update count atomically
|
// Insert answer and update count atomically
|
||||||
|
@ -70,9 +57,6 @@ export async function POST({ locals, request }) {
|
||||||
.set({ answerCount: sql`${questions.answerCount} + 1` })
|
.set({ answerCount: sql`${questions.answerCount} + 1` })
|
||||||
.where(eq(questions.id, questionId));
|
.where(eq(questions.id, questionId));
|
||||||
|
|
||||||
// Valid answer, allow this user to ask one question
|
|
||||||
questionBalanceStore.addQuestions(user);
|
|
||||||
|
|
||||||
return json(newAnswer);
|
return json(newAnswer);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
import { json } from '@sveltejs/kit';
|
|
||||||
import getPoolSize from '$lib/server/rahvatarkus/getPoolSize';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const size = await getPoolSize();
|
|
||||||
|
|
||||||
return json({ size });
|
|
||||||
}
|
|
|
@ -1,11 +1,7 @@
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
import { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm';
|
|
||||||
|
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { questions, answers } from '$lib/server/db/schema';
|
import { questions, answers } from '$lib/server/db/schema';
|
||||||
import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
|
import { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm';
|
||||||
import { ratelimit } from '$lib/server/redis';
|
|
||||||
|
|
||||||
export async function GET({ locals }) {
|
export async function GET({ locals }) {
|
||||||
const { session } = locals;
|
const { session } = locals;
|
||||||
|
@ -13,15 +9,6 @@ export async function GET({ locals }) {
|
||||||
|
|
||||||
const user = session.data.userId;
|
const user = session.data.userId;
|
||||||
|
|
||||||
const { success, reset } = await ratelimit.rahvaQuestionAPI.limit(user);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
const timeRemaining = Math.floor((reset - Date.now()) / 1000);
|
|
||||||
const message = `Võta veits rahulikumalt. Proovi ${timeRemaining}s pärast uuesti.`;
|
|
||||||
|
|
||||||
return json({ error: message }, { status: 429 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the answerCount field and avoid joins
|
// Use the answerCount field and avoid joins
|
||||||
const eligibleQuestions = await db
|
const eligibleQuestions = await db
|
||||||
.select({
|
.select({
|
||||||
|
@ -32,7 +19,6 @@ export async function GET({ locals }) {
|
||||||
.from(questions)
|
.from(questions)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
not(eq(questions.creator, user)),
|
|
||||||
lt(questions.answerCount, 5),
|
lt(questions.answerCount, 5),
|
||||||
not(
|
not(
|
||||||
exists(
|
exists(
|
||||||
|
@ -48,7 +34,7 @@ export async function GET({ locals }) {
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!eligibleQuestions.length) {
|
if (!eligibleQuestions.length) {
|
||||||
return json({ error: '' }, { status: 404 });
|
return json({ error: 'No questions available' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return json(eligibleQuestions[0]);
|
return json(eligibleQuestions[0]);
|
||||||
|
@ -65,17 +51,8 @@ export async function POST({ locals, request }) {
|
||||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { success, reset } = await ratelimit.rahvaQuestion.limit(user);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
const timeRemaining = Math.floor((reset - Date.now()) / 1000);
|
|
||||||
const message = `Võta veits rahulikumalt. API Proovi ${timeRemaining}s pärast uuesti.`;
|
|
||||||
|
|
||||||
return json({ error: message }, { status: 429 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!content?.trim()) {
|
if (!content?.trim()) {
|
||||||
return json({ error: 'Küsimustel on sisu vaja' }, { status: 400 });
|
return json({ error: 'Content is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize content
|
// Normalize content
|
||||||
|
@ -94,7 +71,7 @@ export async function POST({ locals, request }) {
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existingQuestion.length > 0) {
|
if (existingQuestion.length > 0) {
|
||||||
throw new Error('Seda on juba küsitud');
|
throw new Error('Question already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check user's recent questions (optional rate limiting)
|
// Check user's recent questions (optional rate limiting)
|
||||||
|
@ -109,11 +86,7 @@ export async function POST({ locals, request }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (recentQuestions[0].count >= 10) {
|
if (recentQuestions[0].count >= 10) {
|
||||||
throw new Error('Rahu rahu! Oled tunni aja jooksul liiga palju küsimusi esitanud');
|
throw new Error('Too many questions in the last hour');
|
||||||
}
|
|
||||||
|
|
||||||
if (!questionBalanceStore.useQuestion(user)) {
|
|
||||||
throw new Error('Pead vastama teistele enne küsimist');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the new question
|
// Insert the new question
|
||||||
|
@ -130,7 +103,7 @@ export async function POST({ locals, request }) {
|
||||||
|
|
||||||
return json(newQuestion);
|
return json(newQuestion);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e instanceof Error ? e.message : 'Küsimine põrus';
|
const error = e instanceof Error ? e.message : 'Failed to create question';
|
||||||
return json({ error }, { status: 400 });
|
return json({ error }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,10 @@
|
||||||
>
|
>
|
||||||
{#each Object.entries(games) as [href, { image, name }]}
|
{#each Object.entries(games) as [href, { image, name }]}
|
||||||
<a
|
<a
|
||||||
class="shadow-sharp flex aspect-[4/1] h-16 max-w-sm items-center justify-center rounded-xl border-2 border-current bg-contain bg-no-repeat transition-all md:h-20"
|
class="shadow-sharp flex aspect-[4/1] h-16 max-w-sm items-center justify-center rounded-xl border-2 border-current bg-contain bg-no-repeat transition-all md:h-20 {href ===
|
||||||
|
''
|
||||||
|
? 'pressed pointer-events-none'
|
||||||
|
: ''}"
|
||||||
style="background-image: url('{image}')"
|
style="background-image: url('{image}')"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
href="/vinge/{href}"
|
href="/vinge/{href}"
|
||||||
|
|
|
@ -4,13 +4,13 @@ import type { AlbumSolveState } from '$lib/types';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { shuffleArray } from '$lib/utils';
|
import { shuffleArray } from '$lib/utils';
|
||||||
|
|
||||||
|
import { albumState } from '$lib/server/pakubiiti/AlbumState.svelte';
|
||||||
import { playerState } from '$lib/server/pakubiiti/PlayerState.svelte';
|
import { playerState } from '$lib/server/pakubiiti/PlayerState.svelte';
|
||||||
import { ratelimit } from '$lib/server/redis';
|
|
||||||
|
|
||||||
const count = 3;
|
const count = 3;
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
const { session } = event.locals;
|
const { session } = locals;
|
||||||
|
|
||||||
if (!session?.data?.userId) {
|
if (!session?.data?.userId) {
|
||||||
await session.setData({ userId: nanoid() });
|
await session.setData({ userId: nanoid() });
|
||||||
|
@ -35,45 +35,7 @@ export const load: PageServerLoad = async (event) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const useragent = event.request.headers.get('user-agent') || '';
|
const albumData = fetch(`/api/pakubiiti/getAlbums/${count}`)
|
||||||
const ip = event.request.headers.get('cf-connecting-ip') || event.getClientAddress();
|
|
||||||
|
|
||||||
const { success: seshSuccess, reset: seshReset } = await ratelimit.pakubiiti.limit(user, {
|
|
||||||
userAgent: useragent,
|
|
||||||
ip: ip
|
|
||||||
});
|
|
||||||
|
|
||||||
const { success: ipSuccess, reset: ipReset } = await ratelimit.pakubiitiIP.limit(ip, {
|
|
||||||
userAgent: useragent,
|
|
||||||
ip: ip
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!seshSuccess) {
|
|
||||||
const timeRemaining = Math.floor((seshReset - Date.now()) / 1000);
|
|
||||||
const message = `Sesh Proovi ${timeRemaining}s pärast uuesti.`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
stage: stage,
|
|
||||||
highscore: highscore,
|
|
||||||
playing: true,
|
|
||||||
error: { title: 'Võta veits rahulikumalt', message }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ipSuccess) {
|
|
||||||
const timeRemaining = Math.floor((ipReset - Date.now()) / 1000);
|
|
||||||
const message = `IP Proovi ${timeRemaining}s pärast uuesti.`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
stage: stage,
|
|
||||||
highscore: highscore,
|
|
||||||
playing: true,
|
|
||||||
error: { title: 'Võta veits rahulikumalt', message }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumData = event
|
|
||||||
.fetch(`/api/pakubiiti/getAlbums/${count}`)
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
|
@ -91,8 +53,6 @@ export const load: PageServerLoad = async (event) => {
|
||||||
value: album.artists
|
value: album.artists
|
||||||
}));
|
}));
|
||||||
|
|
||||||
playerState.setAlbums(user, data.albums);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
names: shuffleArray(albumNames),
|
names: shuffleArray(albumNames),
|
||||||
images: shuffleArray(albumImages),
|
images: shuffleArray(albumImages),
|
||||||
|
@ -134,7 +94,9 @@ export const actions = {
|
||||||
state.push({ name: name, image: image, artists: artists });
|
state.push({ name: name, image: image, artists: artists });
|
||||||
}
|
}
|
||||||
|
|
||||||
const solved = playerState.score(user, state);
|
const solved = albumState.checkSolve(state);
|
||||||
|
|
||||||
|
playerState.score(user, solved);
|
||||||
|
|
||||||
return { solved: solved };
|
return { solved: solved };
|
||||||
},
|
},
|
||||||
|
|
|
@ -64,35 +64,29 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<AlertDialog.Root open={data.playing === false || data.error}>
|
<AlertDialog.Root open={data.playing === false}>
|
||||||
<AlertDialog.Content>
|
<AlertDialog.Content>
|
||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
<AlertDialog.Title>
|
<AlertDialog.Title>
|
||||||
{#if data.error}
|
{#if data?.highscore && data?.stage && data.highscore === data.stage}
|
||||||
{data.error.title}
|
|
||||||
{:else if data?.highscore && data?.stage && data.highscore === data.stage}
|
|
||||||
Uus parim tulemus!
|
Uus parim tulemus!
|
||||||
{:else}
|
{:else}
|
||||||
Seekord ei vedanud
|
Seekord ei vedanud
|
||||||
{/if}
|
{/if}
|
||||||
</AlertDialog.Title>
|
</AlertDialog.Title>
|
||||||
<AlertDialog.Description>
|
<AlertDialog.Description>
|
||||||
{#if data.error}
|
{#if data.stage === 0}
|
||||||
{data.error.message}
|
|
||||||
{:else if data.stage === 0}
|
|
||||||
Põrusid esimesel katsel.
|
Põrusid esimesel katsel.
|
||||||
{:else}
|
{:else}
|
||||||
Vastasid õigesti <strong>{data.stage} korda.</strong>
|
Vastasid õigesti <strong>{data.stage} korda.</strong>
|
||||||
{/if}
|
{/if}
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
</AlertDialog.Header>
|
</AlertDialog.Header>
|
||||||
{#if !data.error}
|
|
||||||
<AlertDialog.Footer>
|
<AlertDialog.Footer>
|
||||||
<form action="?/restart" method="POST" use:enhance>
|
<form action="?/restart" method="POST" use:enhance>
|
||||||
<AlertDialog.Action type="submit">Uuesti</AlertDialog.Action>
|
<AlertDialog.Action type="submit">Uuesti</AlertDialog.Action>
|
||||||
</form>
|
</form>
|
||||||
</AlertDialog.Footer>
|
</AlertDialog.Footer>
|
||||||
{/if}
|
|
||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ import { formSchema as answerSchema } from './answer-schema';
|
||||||
import { superValidate } from 'sveltekit-superforms';
|
import { superValidate } from 'sveltekit-superforms';
|
||||||
import { zod } from 'sveltekit-superforms/adapters';
|
import { zod } from 'sveltekit-superforms/adapters';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
|
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ fetch, locals }) => {
|
export const load: LayoutServerLoad = async ({ fetch, locals }) => {
|
||||||
const { session } = locals;
|
const { session } = locals;
|
||||||
|
@ -18,19 +17,6 @@ export const load: LayoutServerLoad = async ({ fetch, locals }) => {
|
||||||
|
|
||||||
const user = session.data.userId;
|
const user = session.data.userId;
|
||||||
|
|
||||||
const userBalance = questionBalanceStore.getBalance(user);
|
|
||||||
|
|
||||||
let question: Question | undefined = undefined;
|
|
||||||
|
|
||||||
const poolSize = await fetch('/api/rahvatarkus/pool')
|
|
||||||
.then((res) => {
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
return data.size;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (poolSize !== 0) {
|
|
||||||
const res = await fetch('/api/rahvatarkus/question')
|
const res = await fetch('/api/rahvatarkus/question')
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
@ -40,18 +26,15 @@ export const load: LayoutServerLoad = async ({ fetch, locals }) => {
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let question: Question | undefined = undefined;
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
question = res.data;
|
question = res.data;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: user,
|
||||||
id: user,
|
|
||||||
balance: userBalance
|
|
||||||
},
|
|
||||||
question: question,
|
question: question,
|
||||||
poolSize,
|
|
||||||
question_form: await superValidate(zod(questionSchema)),
|
question_form: await superValidate(zod(questionSchema)),
|
||||||
answer_form: await superValidate({ questionId: question?.id }, zod(answerSchema), {
|
answer_form: await superValidate({ questionId: question?.id }, zod(answerSchema), {
|
||||||
errors: false
|
errors: false
|
||||||
|
|
|
@ -1,30 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
|
||||||
|
|
||||||
import QuestionForm from './question-form.svelte';
|
import QuestionForm from './question-form.svelte';
|
||||||
import AnswerForm from './answer-form.svelte';
|
import AnswerForm from './answer-form.svelte';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
let firstTab = $state('answer');
|
$inspect(data);
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
firstTab = data?.question ? 'answer' : 'question';
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tabs.Root bind:value={firstTab} class="flex w-full max-w-md flex-col items-center gap-1">
|
<QuestionForm {data} />
|
||||||
<Tabs.List class="grid w-full grid-cols-2">
|
<AnswerForm {data} />
|
||||||
<Tabs.Trigger value="answer">Vasta</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="question">Küsi</Tabs.Trigger>
|
|
||||||
</Tabs.List>
|
|
||||||
<Tabs.Content value="answer" class="w-full">
|
|
||||||
<AnswerForm {data} />
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="question" class="w-full">
|
|
||||||
<QuestionForm {data} />
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs.Root>
|
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
|
@ -5,12 +5,11 @@ import { formSchema as answerSchema } from './answer-schema';
|
||||||
import { superValidate } from 'sveltekit-superforms';
|
import { superValidate } from 'sveltekit-superforms';
|
||||||
import { zod } from 'sveltekit-superforms/adapters';
|
import { zod } from 'sveltekit-superforms/adapters';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { ratelimit } from '$lib/server/redis';
|
|
||||||
|
|
||||||
const pageSize = 5;
|
const pageSize = 5;
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
const page = Number(url.searchParams.get('leht')) || 1;
|
const page = Number(url.searchParams.get('leht')) || 0;
|
||||||
|
|
||||||
const archiveRes = fetch(`/api/rahvatarkus/archive/${pageSize}/${(page - 1) * pageSize}`)
|
const archiveRes = fetch(`/api/rahvatarkus/archive/${pageSize}/${(page - 1) * pageSize}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
@ -46,70 +45,6 @@ export const actions: Actions = {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const useragent = event.request.headers.get('user-agent') || '';
|
|
||||||
const ip = event.request.headers.get('cf-connecting-ip') || event.getClientAddress();
|
|
||||||
|
|
||||||
const { success: seshSuccess, reset: seshReset } = await ratelimit.rahvaAnswer.limit(user, {
|
|
||||||
userAgent: useragent,
|
|
||||||
ip: ip
|
|
||||||
});
|
|
||||||
|
|
||||||
const { success: ipSuccess, reset: ipReset } = await ratelimit.rahvaAnswerIP.limit(ip, {
|
|
||||||
userAgent: useragent,
|
|
||||||
ip: ip
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!seshSuccess) {
|
|
||||||
const timeRemaining = Math.floor((seshReset - Date.now()) / 1000);
|
|
||||||
const message = `Võta veits rahulikumalt. Sesh Proovi ${timeRemaining}s pärast uuesti.`;
|
|
||||||
|
|
||||||
if (form.errors.answer) {
|
|
||||||
form.errors.answer.push(message);
|
|
||||||
} else {
|
|
||||||
form.errors.answer = [message];
|
|
||||||
}
|
|
||||||
return fail(429, {
|
|
||||||
form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ipSuccess) {
|
|
||||||
const timeRemaining = Math.floor((ipReset - Date.now()) / 1000);
|
|
||||||
const message = `Võta veits rahulikumalt. IP Proovi ${timeRemaining}s pärast uuesti.`;
|
|
||||||
|
|
||||||
if (form.errors.answer) {
|
|
||||||
form.errors.answer.push(message);
|
|
||||||
} else {
|
|
||||||
form.errors.answer = [message];
|
|
||||||
}
|
|
||||||
return fail(429, {
|
|
||||||
form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const altchaValid = await event
|
|
||||||
.fetch('/api/altcha', { method: 'POST', body: JSON.stringify({ payload: form.data.altcha }) })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!altchaValid) {
|
|
||||||
const message =
|
|
||||||
'Altchale ei meeldinud see. Sa oled kas liiga boti laadse käitumisega või minu implementatsioon on kohutav.';
|
|
||||||
|
|
||||||
if (form.errors.answer) {
|
|
||||||
form.errors.answer.push(message);
|
|
||||||
} else {
|
|
||||||
form.errors.answer = [message];
|
|
||||||
}
|
|
||||||
return fail(429, {
|
|
||||||
form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await event
|
const response = await event
|
||||||
.fetch('/api/rahvatarkus/answer', {
|
.fetch('/api/rahvatarkus/answer', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -128,12 +63,12 @@ export const actions: Actions = {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.data?.error) {
|
const errorMessage = response.data?.error;
|
||||||
|
|
||||||
if (form.errors.answer) {
|
if (form.errors.answer) {
|
||||||
form.errors.answer.push(response.data.error);
|
form.errors.answer.push(errorMessage);
|
||||||
} else {
|
} else {
|
||||||
form.errors.answer = [response.data.error];
|
form.errors.answer = [errorMessage];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
|
@ -162,70 +97,6 @@ export const actions: Actions = {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const useragent = event.request.headers.get('user-agent') || '';
|
|
||||||
const ip = event.request.headers.get('cf-connecting-ip') || event.getClientAddress();
|
|
||||||
|
|
||||||
const { success: seshSuccess, reset: seshReset } = await ratelimit.rahvaQuestion.limit(user, {
|
|
||||||
userAgent: useragent,
|
|
||||||
ip: ip
|
|
||||||
});
|
|
||||||
|
|
||||||
const { success: ipSuccess, reset: ipReset } = await ratelimit.rahvaQuestionIP.limit(ip, {
|
|
||||||
userAgent: useragent,
|
|
||||||
ip: ip
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!seshSuccess) {
|
|
||||||
const timeRemaining = Math.floor((seshReset - Date.now()) / 1000);
|
|
||||||
const message = `Võta veits rahulikumalt. Sesh Proovi ${timeRemaining}s pärast uuesti.`;
|
|
||||||
|
|
||||||
if (form.errors.question) {
|
|
||||||
form.errors.question.push(message);
|
|
||||||
} else {
|
|
||||||
form.errors.question = [message];
|
|
||||||
}
|
|
||||||
return fail(429, {
|
|
||||||
form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ipSuccess) {
|
|
||||||
const timeRemaining = Math.floor((ipReset - Date.now()) / 1000);
|
|
||||||
const message = `Võta veits rahulikumalt. IP Proovi ${timeRemaining}s pärast uuesti.`;
|
|
||||||
|
|
||||||
if (form.errors.question) {
|
|
||||||
form.errors.question.push(message);
|
|
||||||
} else {
|
|
||||||
form.errors.question = [message];
|
|
||||||
}
|
|
||||||
return fail(429, {
|
|
||||||
form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const altchaValid = await event
|
|
||||||
.fetch('/api/altcha', { method: 'POST', body: JSON.stringify({ payload: form.data.altcha }) })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!altchaValid) {
|
|
||||||
const message =
|
|
||||||
'Altchale ei meeldinud see. Sa oled kas liiga boti laadse käitumisega või minu implementatsioon on kohutav.';
|
|
||||||
|
|
||||||
if (form.errors.question) {
|
|
||||||
form.errors.question.push(message);
|
|
||||||
} else {
|
|
||||||
form.errors.question = [message];
|
|
||||||
}
|
|
||||||
return fail(429, {
|
|
||||||
form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await event
|
const response = await event
|
||||||
.fetch('/api/rahvatarkus/question', {
|
.fetch('/api/rahvatarkus/question', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -240,11 +111,13 @@ export const actions: Actions = {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.data?.error) {
|
if (response.data?.error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||||
|
const errorMessage = 'Sellel küsimusel on juba vastus.';
|
||||||
|
|
||||||
if (form.errors.question) {
|
if (form.errors.question) {
|
||||||
form.errors.question.push(response.data.error);
|
form.errors.question.push(errorMessage);
|
||||||
} else {
|
} else {
|
||||||
form.errors.question = [response.data.error];
|
form.errors.question = [errorMessage];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,49 +1,31 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types.js';
|
import type { PageData } from './$types.js';
|
||||||
|
|
||||||
import { MediaQuery } from 'svelte/reactivity';
|
|
||||||
|
|
||||||
import * as Accordion from '$lib/components/ui/accordion/index.js';
|
import * as Accordion from '$lib/components/ui/accordion/index.js';
|
||||||
import * as Pagination from '$lib/components/ui/pagination/index.js';
|
import * as Pagination from '$lib/components/ui/pagination/index.js';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
|
||||||
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
|
||||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const isDesktop = new MediaQuery('(min-width: 768px)');
|
$inspect(data);
|
||||||
const siblingCount = $derived(isDesktop.current ? 1 : 0);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="mb-12 mt-24 flex flex-col items-center text-center font-title">
|
{#await data.streamed.archive}
|
||||||
<h1 class="mb-1 scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
<p>loading</p>
|
||||||
Mida rahvas teab?
|
{:then archive}
|
||||||
</h1>
|
<Accordion.Root type="multiple" class="w-2/3 space-y-6">
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="h-full w-full max-w-prose">
|
|
||||||
{#await data.streamed.archive}
|
|
||||||
<div class="space-y-6">
|
|
||||||
{#each { length: 5 }}
|
|
||||||
<Skeleton class="h-[3.25rem] w-full rounded-lg" />
|
|
||||||
{/each}
|
|
||||||
<Skeleton class="mx-auto !mt-8 h-12 w-2/3 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
{:then archive}
|
|
||||||
<Accordion.Root type="multiple" class="space-y-6">
|
|
||||||
{#each archive.data as question}
|
{#each archive.data as question}
|
||||||
<Accordion.Item disabled={!(question.answers?.length > 0)} value={question.id}>
|
<Accordion.Item disabled={!(question.answers?.length > 0)} value={question.id}>
|
||||||
<Accordion.Trigger>{question.content}?</Accordion.Trigger>
|
<Accordion.Trigger>{question.content}?</Accordion.Trigger>
|
||||||
<Accordion.Content>
|
<Accordion.Content>
|
||||||
|
<ol class="ml-6 list-decimal [&>li]:mt-2">
|
||||||
{#each question.answers as answer}
|
{#each question.answers as answer}
|
||||||
<blockquote
|
<li>
|
||||||
class="border-l-2 bg-muted/25 pl-4 italic leading-7 [&:not(:first-child)]:mt-3"
|
|
||||||
>
|
|
||||||
{answer.content}
|
{answer.content}
|
||||||
</blockquote>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
</ol>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
{/each}
|
{/each}
|
||||||
|
@ -55,19 +37,12 @@
|
||||||
count={archive.meta.total}
|
count={archive.meta.total}
|
||||||
perPage={data.pageSize}
|
perPage={data.pageSize}
|
||||||
page={data.page}
|
page={data.page}
|
||||||
{siblingCount}
|
|
||||||
class="my-8"
|
|
||||||
>
|
>
|
||||||
{#snippet children({ pages, currentPage })}
|
{#snippet children({ pages, currentPage })}
|
||||||
<Pagination.Content class="flex items-center">
|
<Pagination.Content>
|
||||||
<Pagination.Item>
|
<Pagination.Item>
|
||||||
<Pagination.PrevButton
|
<Pagination.PrevButton />
|
||||||
class="hover:bg-dark-10 active:scale-98 inline-flex size-6 items-center justify-center rounded-lg bg-transparent disabled:cursor-not-allowed disabled:text-muted-foreground hover:disabled:bg-transparent sm:size-10 md:mr-4"
|
|
||||||
>
|
|
||||||
<ChevronLeft class="size-4 sm:size-6" />
|
|
||||||
</Pagination.PrevButton>
|
|
||||||
</Pagination.Item>
|
</Pagination.Item>
|
||||||
<div class="flex items-center sm:gap-2.5">
|
|
||||||
{#each pages as page (page.key)}
|
{#each pages as page (page.key)}
|
||||||
{#if page.type === 'ellipsis'}
|
{#if page.type === 'ellipsis'}
|
||||||
<Pagination.Item>
|
<Pagination.Item>
|
||||||
|
@ -81,16 +56,10 @@
|
||||||
</Pagination.Item>
|
</Pagination.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
|
||||||
<Pagination.Item>
|
<Pagination.Item>
|
||||||
<Pagination.NextButton
|
<Pagination.NextButton />
|
||||||
class="hover:bg-dark-10 active:scale-98 inline-flex size-6 items-center justify-center rounded-lg bg-transparent disabled:cursor-not-allowed disabled:text-muted-foreground hover:disabled:bg-transparent sm:size-10 md:ml-4"
|
|
||||||
>
|
|
||||||
<ChevronRight class="size-4 sm:size-6" />
|
|
||||||
</Pagination.NextButton>
|
|
||||||
</Pagination.Item>
|
</Pagination.Item>
|
||||||
</Pagination.Content>
|
</Pagination.Content>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Pagination.Root>
|
</Pagination.Root>
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
|
||||||
|
|
|
@ -6,71 +6,37 @@
|
||||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||||
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||||
|
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
|
||||||
import * as Form from '$lib/components/ui/form/index.js';
|
import * as Form from '$lib/components/ui/form/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
|
||||||
import Altcha from '$lib/components/Altcha.svelte';
|
|
||||||
|
|
||||||
let {
|
let { data }: { data: { answer_form: SuperValidated<Infer<FormSchema>>; question: Question } } =
|
||||||
data
|
$props();
|
||||||
}: {
|
|
||||||
data: { answer_form: SuperValidated<Infer<FormSchema>>; question: Question; poolSize: number };
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const form = superForm(data.answer_form, {
|
const form = superForm(data.answer_form, {
|
||||||
validators: zodClient(formSchema),
|
validators: zodClient(formSchema),
|
||||||
invalidateAll: 'force',
|
invalidateAll: 'force',
|
||||||
onUpdated: ({ form: f }) => {
|
onUpdated: ({ form: f }) => {
|
||||||
if (f.valid) {
|
if (f.valid) {
|
||||||
toast.success('Vastus saadetud.');
|
toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Vastamine nurjus, palun paranda vead.');
|
toast.error('Please fix the errors in the form.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { form: formData, enhance, constraints } = form;
|
const { form: formData, enhance } = form;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root>
|
{#if data.question}
|
||||||
<Card.Header>
|
<form method="POST" class="w-2/3 space-y-6" use:enhance action="?/answer">
|
||||||
{#if !data.question}
|
|
||||||
<Card.Title>Kõik vastatud</Card.Title>
|
|
||||||
<Card.Description>Rahval said kõik küsimused otsa!</Card.Description>
|
|
||||||
{:else}
|
|
||||||
<Card.Title>Vasta vajajale</Card.Title>
|
|
||||||
<Card.Description>Tänutäheks saad vastu ühe küsimuse küsida.</Card.Description>
|
|
||||||
{/if}
|
|
||||||
</Card.Header>
|
|
||||||
{#if !data.question}
|
|
||||||
<Card.Content>
|
|
||||||
{#if data.poolSize === 0}
|
|
||||||
<p class="text-sm leading-6">Sellise erandjuhuga saad ühe korra niisama küsida!</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm leading-6">Äkki tahaksid su sõbrad vastata või on neil küsimusi?</p>
|
|
||||||
{/if}
|
|
||||||
</Card.Content>
|
|
||||||
{:else}
|
|
||||||
<form method="POST" use:enhance action="?/answer">
|
|
||||||
<Card.Content>
|
|
||||||
<Form.Field {form} name="answer">
|
<Form.Field {form} name="answer">
|
||||||
<Form.Control>
|
<Form.Control>
|
||||||
{#snippet children({ props })}
|
{#snippet children({ props })}
|
||||||
<Form.Label class="transition-colors">{data.question.content}?</Form.Label>
|
<Form.Label>{data.question.content}?</Form.Label>
|
||||||
<Textarea
|
<Input {...props} bind:value={$formData.answer} />
|
||||||
{...props}
|
|
||||||
bind:value={$formData.answer}
|
|
||||||
class="resize-none transition-colors"
|
|
||||||
/>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
<div class="flex justify-between">
|
|
||||||
<Form.FieldErrors />
|
<Form.FieldErrors />
|
||||||
<Form.Description>
|
|
||||||
{$formData.answer.length}/{$constraints.answer?.maxlength}
|
|
||||||
</Form.Description>
|
|
||||||
</div>
|
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
<Form.Field {form} name="questionId">
|
<Form.Field {form} name="questionId">
|
||||||
<Form.Control>
|
<Form.Control>
|
||||||
|
@ -78,18 +44,8 @@
|
||||||
<Input type="hidden" {...props} bind:value={$formData.questionId} />
|
<Input type="hidden" {...props} bind:value={$formData.questionId} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
|
<Form.FieldErrors />
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
<Form.Field {form} name="altcha" class="mx-auto mt-3 w-[var(--altcha-max-width)]">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Altcha {...props} bind:value={$formData.altcha} />
|
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
</Form.Field>
|
|
||||||
</Card.Content>
|
|
||||||
<Card.Footer class="justify-center">
|
|
||||||
<Form.Button>Vasta</Form.Button>
|
<Form.Button>Vasta</Form.Button>
|
||||||
</Card.Footer>
|
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Root>
|
|
||||||
|
|
|
@ -2,11 +2,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
export const formSchema = z.object({
|
export const formSchema = z.object({
|
||||||
questionId: z.string().length(21),
|
questionId: z.string().length(21),
|
||||||
answer: z
|
answer: z.string().min(2).max(250)
|
||||||
.string()
|
|
||||||
.min(2, 'Vastus peab olema vähemalt 2 tähemärki.')
|
|
||||||
.max(150, 'Vastus ei või olla pikem kui 150 tähemärki.'),
|
|
||||||
altcha: z.string()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FormSchema = typeof formSchema;
|
export type FormSchema = typeof formSchema;
|
||||||
|
|
|
@ -1,83 +1,41 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formSchema, type FormSchema } from './question-schema';
|
import { formSchema, type FormSchema } from './question-schema';
|
||||||
import type { Question } from '$lib/types';
|
|
||||||
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||||
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||||
|
|
||||||
import * as Form from '$lib/components/ui/form/index.js';
|
import * as Form from '$lib/components/ui/form/index.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
import Altcha from '$lib/components/Altcha.svelte';
|
|
||||||
|
|
||||||
let {
|
let { data }: { data: { question_form: SuperValidated<Infer<FormSchema>> } } = $props();
|
||||||
data
|
|
||||||
}: {
|
|
||||||
data: {
|
|
||||||
question_form: SuperValidated<Infer<FormSchema>>;
|
|
||||||
question: Question;
|
|
||||||
poolSize: number;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
balance: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const form = superForm(data.question_form, {
|
const form = superForm(data.question_form, {
|
||||||
validators: zodClient(formSchema),
|
validators: zodClient(formSchema),
|
||||||
|
invalidateAll: false,
|
||||||
resetForm: true,
|
resetForm: true,
|
||||||
onUpdated: ({ form: f }) => {
|
onUpdated: ({ form: f }) => {
|
||||||
if (f.valid) {
|
if (f.valid) {
|
||||||
toast.success('Küsimus esitatud.');
|
toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Küsimine nurjus, palun paranda vead.');
|
toast.error('Please fix the errors in the form.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { form: formData, enhance, constraints } = form;
|
const { form: formData, enhance } = form;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root>
|
<form method="POST" class="w-2/3 space-y-6" use:enhance action="?/question">
|
||||||
<Card.Header>
|
|
||||||
<Card.Title>Küsi rahvalt</Card.Title>
|
|
||||||
<Card.Description
|
|
||||||
>Sul on alles {data.user.balance > 0 ? data.user.balance : data.poolSize > 0 ? 0 : 1} küsimust.</Card.Description
|
|
||||||
>
|
|
||||||
</Card.Header>
|
|
||||||
{#if data.user.balance === 0 && (!data.question || data.poolSize > 0)}
|
|
||||||
<Card.Content>
|
|
||||||
<p class="text-sm leading-6">Enne küsimist pead kõigepealt vastama teistele!</p>
|
|
||||||
</Card.Content>
|
|
||||||
{:else}
|
|
||||||
<form method="POST" use:enhance action="?/question">
|
|
||||||
<Card.Content>
|
|
||||||
<Form.Field {form} name="question">
|
<Form.Field {form} name="question">
|
||||||
<Form.Control>
|
<Form.Control>
|
||||||
{#snippet children({ props })}
|
{#snippet children({ props })}
|
||||||
<Form.Label class="transition-colors">Küsimus rahvale</Form.Label>
|
<Form.Label>Uus küsimus</Form.Label>
|
||||||
<Input {...props} bind:value={$formData.question} class="transition-colors" />
|
<Input {...props} bind:value={$formData.question} />
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
<Form.Description class="flex justify-between">
|
|
||||||
<Form.FieldErrors />
|
|
||||||
<p>{$formData.question.length}/{$constraints.question?.maxlength}</p>
|
|
||||||
</Form.Description>
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field {form} name="altcha" class="mx-auto mt-3 w-[var(--altcha-max-width)]">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Altcha {...props} bind:value={$formData.altcha} />
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
|
<Form.Description>Küsi ükskõik mida sellelt kollektiiv intelektilt.</Form.Description>
|
||||||
<Form.FieldErrors />
|
<Form.FieldErrors />
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
</Card.Content>
|
|
||||||
<Card.Footer class="justify-center">
|
|
||||||
<Form.Button>Küsi</Form.Button>
|
<Form.Button>Küsi</Form.Button>
|
||||||
</Card.Footer>
|
</form>
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</Card.Root>
|
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const formSchema = z.object({
|
export const formSchema = z.object({
|
||||||
question: z
|
question: z.string().min(2).max(50)
|
||||||
.string()
|
|
||||||
.min(2, 'Küsimus peab olema vähemalt 2 tähemärki.')
|
|
||||||
.max(50, 'Küsimus ei või olla pikem kui 50 tähemärki.'),
|
|
||||||
altcha: z.string()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FormSchema = typeof formSchema;
|
export type FormSchema = typeof formSchema;
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { SoundCheckpoint } from '$lib/types';
|
|
||||||
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Tween } from 'svelte/motion';
|
import { Tween } from 'svelte/motion';
|
||||||
import { expoOut } from 'svelte/easing';
|
import { expoOut } from 'svelte/easing';
|
||||||
|
@ -13,11 +11,218 @@
|
||||||
import ArrowUpToLine from 'lucide-svelte/icons/arrow-up-to-line';
|
import ArrowUpToLine from 'lucide-svelte/icons/arrow-up-to-line';
|
||||||
import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down';
|
import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down';
|
||||||
|
|
||||||
import { watch } from 'runed';
|
import SevenSegmentDigit from './SevenSegmentDigit.svelte';
|
||||||
import { getTimeRemaining } from '$lib/utils';
|
import { getTimeRemaining } from '$lib/utils';
|
||||||
|
|
||||||
import SevenSegmentDigit from './SevenSegmentDigit.svelte';
|
import roomImg from '$lib/assets/vaukuivali/roomtone.jpg?enhanced';
|
||||||
import { soundCheckpoints } from './checkpoints';
|
import watchImg from '$lib/assets/vaukuivali/oldwatch.jpg?enhanced';
|
||||||
|
import convoImg from '$lib/assets/vaukuivali/conversation.jpg?enhanced';
|
||||||
|
import gennImg from '$lib/assets/vaukuivali/genn.webp?enhanced';
|
||||||
|
import tvImg from '$lib/assets/vaukuivali/tv.jpg?enhanced';
|
||||||
|
import trafficImg from '$lib/assets/vaukuivali/kaubamaja.jpg?enhanced';
|
||||||
|
import harleyImg from '$lib/assets/vaukuivali/harley.jpg?enhanced';
|
||||||
|
import landingImg from '$lib/assets/vaukuivali/landing.jpg?enhanced';
|
||||||
|
import carCrashImg from '$lib/assets/vaukuivali/carcrash.jpg?enhanced';
|
||||||
|
import chainsawImg from '$lib/assets/vaukuivali/chainsaw.jpg?enhanced';
|
||||||
|
import jetImg from '$lib/assets/vaukuivali/fighters.jpg?enhanced';
|
||||||
|
import hearingaidImg from '$lib/assets/vaukuivali/eardamage.jpg?enhanced';
|
||||||
|
|
||||||
|
interface SoundCheckpoint {
|
||||||
|
db: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
crossedTime: undefined | Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const soundCheckpoints: SoundCheckpoint[] = $state([
|
||||||
|
{
|
||||||
|
db: 0,
|
||||||
|
title: '',
|
||||||
|
description: 'Kesket metsa mingis koopas, kedagi pole ümber',
|
||||||
|
image: undefined,
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 30,
|
||||||
|
title: '"Vaikus"',
|
||||||
|
description: 'ehk elutoa pasiivne müra',
|
||||||
|
image: {
|
||||||
|
src: roomImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Kam Idris',
|
||||||
|
href: 'https://unsplash.com/@ka_idris'
|
||||||
|
},
|
||||||
|
alt: 'Modernse ja minimalistliku disainiga siseruum.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 40,
|
||||||
|
title: 'Tikk takk',
|
||||||
|
description: 'Mehaanilise kella tiksumine (va täistundidel)',
|
||||||
|
image: {
|
||||||
|
src: watchImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'János Venczák',
|
||||||
|
href: 'https://unsplash.com/@venczakjanos'
|
||||||
|
},
|
||||||
|
alt: 'Lahti võetud vanamoodne käekell. Näha on kella sisemust, hammasrattaid.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 50,
|
||||||
|
title: 'Tava jutt',
|
||||||
|
description: 'Rahulik vestlus kodus',
|
||||||
|
image: {
|
||||||
|
src: convoImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Toa Heftiba',
|
||||||
|
href: 'https://unsplash.com/@heftiba'
|
||||||
|
},
|
||||||
|
alt: 'Noor paar köögis. Mees lõikab taldriku peal pannkooki, naine istub ta kõrval pliidi peal ja vaatab.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 60,
|
||||||
|
title: 'Ma sain nurgad täis',
|
||||||
|
description: 'Bingo õhtu Gennis (keset mängu)',
|
||||||
|
image: {
|
||||||
|
src: gennImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Laila Kaasik',
|
||||||
|
href: 'https://tartu.postimees.ee/8154041/lallavad-pidutsejad-panid-tartu-otsima-tasakaalu-ooelu-ja-oorahu-vahel'
|
||||||
|
},
|
||||||
|
alt: 'Genialistide klubi tegutseb Tartus Magasini tänavas. Pilt on õhtusest ajast, rohkelt inimesti klubi välialal.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 70,
|
||||||
|
title: 'Pult on kadunud',
|
||||||
|
description: 'Telekas, mis mängib natuke liiga valjult',
|
||||||
|
image: {
|
||||||
|
src: tvImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Jonas Leupe',
|
||||||
|
href: 'https://unsplash.com/@jonasleupe'
|
||||||
|
},
|
||||||
|
alt: 'Keegi vaatab televiisorist filmi. Esiplaanil fookuses teleka pult, tagaplaanil udune tuba, mille seina vastas on telekas.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 80,
|
||||||
|
title: 'USAs oleks hullem',
|
||||||
|
description: 'Riia mäe liiklus (ootad bussi Kaubamaja ees)',
|
||||||
|
image: {
|
||||||
|
src: trafficImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Google Street View',
|
||||||
|
href: 'https://maps.app.goo.gl/ZfADP4LnUid7d571A'
|
||||||
|
},
|
||||||
|
alt: 'Aastal 2012 tehtud Google Street View pilt. Näha on Tartu Kaubamaja ning selle Riia tänava küljel olevat bussipeatust.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 90,
|
||||||
|
title: 'USAs oleks rohkem',
|
||||||
|
description: 'Harley sõidab sinust mööda',
|
||||||
|
image: {
|
||||||
|
src: harleyImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Harley-Davidson',
|
||||||
|
href: 'https://unsplash.com/@harleydavidson'
|
||||||
|
},
|
||||||
|
alt: 'Uue välimusega Harley-Davidson mootorrattas sõidab kiiresti mööda sirget maanteed.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 100,
|
||||||
|
title: 'Põgenesid terminalist',
|
||||||
|
description: 'Boeing 707 1 meremiil enne maandumist',
|
||||||
|
image: {
|
||||||
|
src: landingImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Scott Fillmer',
|
||||||
|
href: 'https://unsplash.com/@scottfillmer'
|
||||||
|
},
|
||||||
|
alt: 'Continental Airlines Boeing 777 maandub uduses Houston IAH lennujaamas.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 110,
|
||||||
|
title: 'Maanteeraev',
|
||||||
|
description: 'Autosignaal 1m kauguselt',
|
||||||
|
image: {
|
||||||
|
src: carCrashImg,
|
||||||
|
credit: {
|
||||||
|
type: 'instagram',
|
||||||
|
author: 'Jordan Besson',
|
||||||
|
href: 'https://www.instagram.com/mr.blue.photographie'
|
||||||
|
},
|
||||||
|
alt: 'Dramaatiline auto trikk filmi jaoks. Kahe auto kokkupõrge.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 120,
|
||||||
|
title: 'Mootorsaag',
|
||||||
|
description: 'Nüüd on juba valus. Soovitan kanda kõrvatroppe.',
|
||||||
|
image: {
|
||||||
|
src: chainsawImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Benjamin Jopen',
|
||||||
|
href: 'https://unsplash.com/@benjopen'
|
||||||
|
},
|
||||||
|
alt: 'Oranži ja musta värvi mootorsega lõigatakse langenud puud väiksemateks tükkideks.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 130,
|
||||||
|
title: 'Kuidas sa nii lähedale said?',
|
||||||
|
description: 'Turboreaktiivmootoriga hävitaja lendutõus 15m kauguselt',
|
||||||
|
image: {
|
||||||
|
src: jetImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Colin Lloyd',
|
||||||
|
href: 'https://unsplash.com/@onthesearchforpineapples'
|
||||||
|
},
|
||||||
|
alt: 'Kaheksa F-16 hävitajat lendavad koos formatsioonis taevas.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: 150,
|
||||||
|
title: 'Aia mu kõrvad',
|
||||||
|
description: 'Tubli töö! Su trummikile rebenes!',
|
||||||
|
image: {
|
||||||
|
src: hearingaidImg,
|
||||||
|
credit: {
|
||||||
|
type: 'web',
|
||||||
|
author: 'Mark Paton',
|
||||||
|
href: 'https://unsplash.com/@heftiba'
|
||||||
|
},
|
||||||
|
alt: 'Lähivõte inimesest sisestamas oma kõrva kuuldeaparaati.'
|
||||||
|
},
|
||||||
|
crossedTime: undefined
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
// Source: Claude 3.5 Sonnet
|
// Source: Claude 3.5 Sonnet
|
||||||
function scrollToDecibels(scroll: number) {
|
function scrollToDecibels(scroll: number) {
|
||||||
|
@ -30,11 +235,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: Claude 3.5 Sonnet
|
// Source: Claude 3.5 Sonnet
|
||||||
function getCurrentCheckpoint(arr: number[], current: number) {
|
function getCurrentCheckpoint(arr: SoundCheckpoint[], current: number) {
|
||||||
return (
|
return (
|
||||||
arr.reduce(
|
arr.reduce(
|
||||||
(prev: SoundCheckpoint | undefined, item) =>
|
(prev: SoundCheckpoint | undefined, item) =>
|
||||||
item <= current && (!prev || item > prev) ? item : prev,
|
item.db <= current && (!prev || item.db > prev.db) ? item : prev,
|
||||||
undefined
|
undefined
|
||||||
) || arr.at(0)
|
) || arr.at(0)
|
||||||
);
|
);
|
||||||
|
@ -55,9 +260,7 @@
|
||||||
|
|
||||||
let innerHeight = $state(0);
|
let innerHeight = $state(0);
|
||||||
let innerWidth = $state(0);
|
let innerWidth = $state(0);
|
||||||
|
let prevCheckpoint: SoundCheckpoint | undefined = $state(undefined);
|
||||||
let startTime: Date | undefined = $state();
|
|
||||||
let firstScroll: Date | undefined = $state();
|
|
||||||
|
|
||||||
let scrollScale = $derived(innerHeight * 0.1);
|
let scrollScale = $derived(innerHeight * 0.1);
|
||||||
let containerHeight = $derived(decibelsToScroll(150) + innerHeight + innerHeight);
|
let containerHeight = $derived(decibelsToScroll(150) + innerHeight + innerHeight);
|
||||||
|
@ -67,10 +270,7 @@
|
||||||
|
|
||||||
let currentDecibel = $derived(scrollToDecibels(scrollY.target));
|
let currentDecibel = $derived(scrollToDecibels(scrollY.target));
|
||||||
let currentDecibelTweened = $derived(scrollToDecibels(scrollY.current));
|
let currentDecibelTweened = $derived(scrollToDecibels(scrollY.current));
|
||||||
|
let currentCheckpoint = $derived(getCurrentCheckpoint(soundCheckpoints, currentDecibel));
|
||||||
let checkpointDecibels = $derived(Object.keys(soundCheckpoints).map((value) => Number(value)));
|
|
||||||
let currentCheckpoint = $derived(getCurrentCheckpoint(checkpointDecibels, currentDecibel));
|
|
||||||
let checkpointTimes: Record<number, Date> = $state({});
|
|
||||||
|
|
||||||
let decibelMeter = $derived.by(() => {
|
let decibelMeter = $derived.by(() => {
|
||||||
const clampedValue = Math.min(999.99, Math.max(0, currentDecibel));
|
const clampedValue = Math.min(999.99, Math.max(0, currentDecibel));
|
||||||
|
@ -91,64 +291,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch.pre(
|
$effect(() => {
|
||||||
() => currentCheckpoint,
|
if (
|
||||||
(curr, prev) => {
|
currentCheckpoint?.title &&
|
||||||
if (curr === prev) return;
|
currentCheckpoint != prevCheckpoint &&
|
||||||
|
!currentCheckpoint?.crossedTime
|
||||||
if (checkpointTimes[curr]) return;
|
) {
|
||||||
|
currentCheckpoint.crossedTime = new Date();
|
||||||
checkpointTimes[curr] = new Date();
|
|
||||||
|
|
||||||
// We reached the end
|
|
||||||
// Fill out any checkpoint times we missed with crude predictions
|
|
||||||
if (curr === checkpointDecibels.at(-1) && checkpointDecibels.length > 1) {
|
|
||||||
for (let i = 0; i < checkpointDecibels.length; i++) {
|
|
||||||
const db = checkpointDecibels[i];
|
|
||||||
const capturedTime = checkpointTimes[db];
|
|
||||||
|
|
||||||
if (!capturedTime) {
|
|
||||||
// If the next closest previous time is page load
|
|
||||||
// use first scroll for a more accurate prediction (if possible)
|
|
||||||
const prevDb = checkpointDecibels[Math.max(i - 1, 0)];
|
|
||||||
const prevTime = prevDb === 0 && firstScroll ? firstScroll : checkpointTimes[prevDb];
|
|
||||||
|
|
||||||
if (!prevTime) {
|
|
||||||
checkpointTimes[db] = firstScroll ? firstScroll : checkpointTimes[0];
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
const nextDb = checkpointDecibels[Math.min(i + 1, checkpointDecibels.length - 1)];
|
|
||||||
const nextTime = checkpointTimes[nextDb];
|
|
||||||
|
|
||||||
if (!nextTime) {
|
|
||||||
checkpointTimes[db] = prevTime;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
checkpointTimes[db] = new Date((prevTime.getTime() + nextTime.getTime()) / 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the time of first scroll
|
|
||||||
watch.pre(
|
|
||||||
() => scrollY.current,
|
|
||||||
() => {
|
|
||||||
if (!startTime) return;
|
|
||||||
if (firstScroll) return;
|
|
||||||
|
|
||||||
firstScroll = new Date();
|
|
||||||
checkpointTimes[checkpointDecibels[0]] = firstScroll;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
scrollY.set(0, { duration: 0 });
|
soundCheckpoints[0].crossedTime = new Date();
|
||||||
|
scrollY.target = 0;
|
||||||
startTime = new Date();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -164,11 +319,11 @@
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet timeCard(db: number | undefined)}
|
{#snippet timeCard(point: SoundCheckpoint)}
|
||||||
<div class="rounded-md border px-4 py-3 font-mono text-sm">
|
<div class="rounded-md border px-4 py-3 font-mono text-sm">
|
||||||
<p class="leading-7">
|
<p class="leading-7">
|
||||||
<strong>{db}dBA</strong> -
|
<strong>{point.db}dBA</strong> -
|
||||||
<span>{getElapsedTime(firstScroll, db ? checkpointTimes[db] : firstScroll)}</span>
|
<span>{getElapsedTime(soundCheckpoints.at(0)?.crossedTime, point.crossedTime)}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
@ -210,7 +365,7 @@
|
||||||
<div
|
<div
|
||||||
class="relative h-[70svh] w-4 bg-gradient-to-b from-lime-300 via-yellow-400 to-red-500 dark:from-lime-500 dark:via-yellow-400 dark:to-red-500"
|
class="relative h-[70svh] w-4 bg-gradient-to-b from-lime-300 via-yellow-400 to-red-500 dark:from-lime-500 dark:via-yellow-400 dark:to-red-500"
|
||||||
>
|
>
|
||||||
{#each checkpointDecibels as db}
|
{#each soundCheckpoints as { db }}
|
||||||
<div
|
<div
|
||||||
transition:fade
|
transition:fade
|
||||||
style="top: calc({(db / 150) * 70}svh - 0.5rem)"
|
style="top: calc({(db / 150) * 70}svh - 0.5rem)"
|
||||||
|
@ -229,21 +384,18 @@
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex w-full max-w-2xl flex-col-reverse items-center gap-8 self-center px-12 md:grid md:gap-0 md:*:[grid-area:1/1/2/2]"
|
class="mx-auto flex w-full max-w-2xl flex-col-reverse items-center gap-8 self-center px-12 md:grid md:gap-0 md:*:[grid-area:1/1/2/2]"
|
||||||
>
|
>
|
||||||
<Image
|
<Image image={currentCheckpoint?.image} class="aspect-square object-cover " />
|
||||||
image={soundCheckpoints[currentCheckpoint]?.image}
|
|
||||||
class="aspect-square object-cover "
|
|
||||||
/>
|
|
||||||
<header
|
<header
|
||||||
class="flex flex-col items-center py-4 text-center font-title backdrop-blur-sm backdrop-grayscale md:bg-background/75 dark:md:bg-background/90"
|
class="flex flex-col items-center py-4 text-center font-title backdrop-blur-sm backdrop-grayscale md:bg-background/75 dark:md:bg-background/90"
|
||||||
>
|
>
|
||||||
<h1 class="mb-1 scroll-m-20 text-5xl font-extrabold tracking-tight lg:text-6xl">
|
<h1 class="mb-1 scroll-m-20 text-5xl font-extrabold tracking-tight lg:text-6xl">
|
||||||
{#if currentCheckpoint === 0}
|
{#if currentCheckpoint?.db === 0}
|
||||||
Vau kui vali!
|
Vau kui vali!
|
||||||
{:else}
|
{:else}
|
||||||
{soundCheckpoints[currentCheckpoint]?.title}
|
{currentCheckpoint?.title}
|
||||||
{/if}
|
{/if}
|
||||||
</h1>
|
</h1>
|
||||||
{#if currentCheckpoint === 0}
|
{#if currentCheckpoint?.db === 0}
|
||||||
<p class="max-w-prose text-2xl font-semibold leading-7 text-muted-foreground">
|
<p class="max-w-prose text-2xl font-semibold leading-7 text-muted-foreground">
|
||||||
Nagu paljud võivad teada, on detsibellide skaala logaritmiline.<br /> 60dB on 2x valjem,
|
Nagu paljud võivad teada, on detsibellide skaala logaritmiline.<br /> 60dB on 2x valjem,
|
||||||
kui 50dB.
|
kui 50dB.
|
||||||
|
@ -256,7 +408,7 @@
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="max-w-prose text-2xl font-semibold leading-7 text-primary/80">
|
<p class="max-w-prose text-2xl font-semibold leading-7 text-primary/80">
|
||||||
{soundCheckpoints[currentCheckpoint]?.description}
|
{currentCheckpoint?.description}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
@ -276,10 +428,10 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
</div>
|
</div>
|
||||||
{@render timeCard(checkpointDecibels.at(-1))}
|
{@render timeCard(soundCheckpoints.at(-1) as SoundCheckpoint)}
|
||||||
<Collapsible.Content class="space-y-2">
|
<Collapsible.Content class="space-y-2">
|
||||||
{#each checkpointDecibels.slice(1, -1).reverse() as db}
|
{#each soundCheckpoints.slice(1, -1).reverse() as point}
|
||||||
{@render timeCard(db)}
|
{@render timeCard(point)}
|
||||||
{/each}
|
{/each}
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
|
@ -296,7 +448,7 @@
|
||||||
<a
|
<a
|
||||||
href="https://www.chem.purdue.edu/chemsafety/Training/PPETrain/dblevels.htm"
|
href="https://www.chem.purdue.edu/chemsafety/Training/PPETrain/dblevels.htm"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="underline underline-offset-4">Purdue University PPE training materials</a
|
class=" underline underline-offset-4">Purdue University PPE training materials</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,178 +0,0 @@
|
||||||
import { ImageCreditType, type SoundCheckpoint } from '$lib/types';
|
|
||||||
|
|
||||||
import roomImg from '$lib/assets/vaukuivali/roomtone.jpg?enhanced';
|
|
||||||
import watchImg from '$lib/assets/vaukuivali/oldwatch.jpg?enhanced';
|
|
||||||
import convoImg from '$lib/assets/vaukuivali/conversation.jpg?enhanced';
|
|
||||||
import gennImg from '$lib/assets/vaukuivali/genn.webp?enhanced';
|
|
||||||
import tvImg from '$lib/assets/vaukuivali/tv.jpg?enhanced';
|
|
||||||
import trafficImg from '$lib/assets/vaukuivali/kaubamaja.jpg?enhanced';
|
|
||||||
import harleyImg from '$lib/assets/vaukuivali/harley.jpg?enhanced';
|
|
||||||
import landingImg from '$lib/assets/vaukuivali/landing.jpg?enhanced';
|
|
||||||
import carCrashImg from '$lib/assets/vaukuivali/carcrash.jpg?enhanced';
|
|
||||||
import chainsawImg from '$lib/assets/vaukuivali/chainsaw.jpg?enhanced';
|
|
||||||
import jetImg from '$lib/assets/vaukuivali/fighters.jpg?enhanced';
|
|
||||||
import hearingaidImg from '$lib/assets/vaukuivali/eardamage.jpg?enhanced';
|
|
||||||
|
|
||||||
export const soundCheckpoints: Record<number, SoundCheckpoint> = {
|
|
||||||
0: {
|
|
||||||
title: '',
|
|
||||||
description: 'Kesket metsa mingis koopas, kedagi pole ümber',
|
|
||||||
image: undefined
|
|
||||||
},
|
|
||||||
30: {
|
|
||||||
title: '"Vaikus"',
|
|
||||||
description: 'ehk elutoa pasiivne müra',
|
|
||||||
image: {
|
|
||||||
src: roomImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Kam Idris',
|
|
||||||
href: 'https://unsplash.com/@ka_idris'
|
|
||||||
},
|
|
||||||
alt: 'Modernse ja minimalistliku disainiga siseruum.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
40: {
|
|
||||||
title: 'Tikk takk',
|
|
||||||
description: 'Mehaanilise kella tiksumine (va täistundidel)',
|
|
||||||
image: {
|
|
||||||
src: watchImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'János Venczák',
|
|
||||||
href: 'https://unsplash.com/@venczakjanos'
|
|
||||||
},
|
|
||||||
alt: 'Lahti võetud vanamoodne käekell. Näha on kella sisemust, hammasrattaid.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
50: {
|
|
||||||
title: 'Tava jutt',
|
|
||||||
description: 'Rahulik vestlus kodus',
|
|
||||||
image: {
|
|
||||||
src: convoImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Toa Heftiba',
|
|
||||||
href: 'https://unsplash.com/@heftiba'
|
|
||||||
},
|
|
||||||
alt: 'Noor paar köögis. Mees lõikab taldriku peal pannkooki, naine istub ta kõrval pliidi peal ja vaatab.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
60: {
|
|
||||||
title: 'Ma sain nurgad täis',
|
|
||||||
description: 'Bingo õhtu Gennis (keset mängu)',
|
|
||||||
image: {
|
|
||||||
src: gennImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Laila Kaasik',
|
|
||||||
href: 'https://tartu.postimees.ee/8154041/lallavad-pidutsejad-panid-tartu-otsima-tasakaalu-ooelu-ja-oorahu-vahel'
|
|
||||||
},
|
|
||||||
alt: 'Genialistide klubi tegutseb Tartus Magasini tänavas. Pilt on õhtusest ajast, rohkelt inimesti klubi välialal.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
70: {
|
|
||||||
title: 'Pult on kadunud',
|
|
||||||
description: 'Telekas, mis mängib natuke liiga valjult',
|
|
||||||
image: {
|
|
||||||
src: tvImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Jonas Leupe',
|
|
||||||
href: 'https://unsplash.com/@jonasleupe'
|
|
||||||
},
|
|
||||||
alt: 'Keegi vaatab televiisorist filmi. Esiplaanil fookuses teleka pult, tagaplaanil udune tuba, mille seina vastas on telekas.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
80: {
|
|
||||||
title: 'USAs oleks hullem',
|
|
||||||
description: 'Riia mäe liiklus (ootad bussi Kaubamaja ees)',
|
|
||||||
image: {
|
|
||||||
src: trafficImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Google Street View',
|
|
||||||
href: 'https://maps.app.goo.gl/ZfADP4LnUid7d571A'
|
|
||||||
},
|
|
||||||
alt: 'Aastal 2012 tehtud Google Street View pilt. Näha on Tartu Kaubamaja ning selle Riia tänava küljel olevat bussipeatust.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
90: {
|
|
||||||
title: 'USAs oleks rohkem',
|
|
||||||
description: 'Harley sõidab sinust mööda',
|
|
||||||
image: {
|
|
||||||
src: harleyImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Harley-Davidson',
|
|
||||||
href: 'https://unsplash.com/@harleydavidson'
|
|
||||||
},
|
|
||||||
alt: 'Uue välimusega Harley-Davidson mootorrattas sõidab kiiresti mööda sirget maanteed.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
100: {
|
|
||||||
title: 'Põgenesid terminalist',
|
|
||||||
description: 'Boeing 707 1 meremiil enne maandumist',
|
|
||||||
image: {
|
|
||||||
src: landingImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Scott Fillmer',
|
|
||||||
href: 'https://unsplash.com/@scottfillmer'
|
|
||||||
},
|
|
||||||
alt: 'Continental Airlines Boeing 777 maandub uduses Houston IAH lennujaamas.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
110: {
|
|
||||||
title: 'Maanteeraev',
|
|
||||||
description: 'Autosignaal 1m kauguselt',
|
|
||||||
image: {
|
|
||||||
src: carCrashImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.instagram,
|
|
||||||
author: 'Jordan Besson',
|
|
||||||
href: 'https://www.instagram.com/mr.blue.photographie'
|
|
||||||
},
|
|
||||||
alt: 'Dramaatiline auto trikk filmi jaoks. Kahe auto kokkupõrge.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
120: {
|
|
||||||
title: 'Mootorsaag',
|
|
||||||
description: 'Nüüd on juba valus. Soovitan kanda kõrvatroppe.',
|
|
||||||
image: {
|
|
||||||
src: chainsawImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Benjamin Jopen',
|
|
||||||
href: 'https://unsplash.com/@benjopen'
|
|
||||||
},
|
|
||||||
alt: 'Oranži ja musta värvi mootorsega lõigatakse langenud puud väiksemateks tükkideks.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
130: {
|
|
||||||
title: 'Kuidas sa nii lähedale said?',
|
|
||||||
description: 'Turboreaktiivmootoriga hävitaja lendutõus 15m kauguselt',
|
|
||||||
image: {
|
|
||||||
src: jetImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Colin Lloyd',
|
|
||||||
href: 'https://unsplash.com/@onthesearchforpineapples'
|
|
||||||
},
|
|
||||||
alt: 'Kaheksa F-16 hävitajat lendavad koos formatsioonis taevas.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
150: {
|
|
||||||
title: 'Aia mu kõrvad',
|
|
||||||
description: 'Tubli töö! Su trummikile rebenes!',
|
|
||||||
image: {
|
|
||||||
src: hearingaidImg,
|
|
||||||
credit: {
|
|
||||||
type: ImageCreditType.web,
|
|
||||||
author: 'Mark Paton',
|
|
||||||
href: 'https://unsplash.com/@heftiba'
|
|
||||||
},
|
|
||||||
alt: 'Lähivõte inimesest sisestamas oma kõrva kuuldeaparaati.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
Loading…
Add table
Reference in a new issue