Compare commits

...

11 commits

Author SHA1 Message Date
Mihkel Martin Kasterpalu
c6ec335b04 Add altcha captcha to rahvatarkus, adjust some wordings in rahvatarkus 2025-02-12 04:33:08 +02:00
Mihkel Martin Kasterpalu
4363f56c23 Add Skeleton to rahvatarkus while loading instead of text 2025-02-12 03:26:47 +02:00
Mihkel Martin Kasterpalu
25ffa850e3 Add rahvatakus to games, remove "rohkem mänge soon" from games
The placeholder "more games soon" button isn't needed anymore, because
there already is enough on the site
2025-02-12 02:02:37 +02:00
Mihkel Martin Kasterpalu
2130ae5c39 Rewrite some of vaukuivali to optimize split timer
Use watch from runed for more finegrained updates
Start time only after the first scroll
Add fallbacks/estimations if we still didn't collect the time
Track the current checkpoint using only the decibel, as other info is
now in a Record accessible via the decibel as the key
2025-02-12 01:58:43 +02:00
Mihkel Martin Kasterpalu
626dd22cb1 fix image credit type in vaukuivali and projects 2025-02-12 00:27:57 +02:00
Mihkel Martin Kasterpalu
d882c670ed Optimize pakubiiti player state
Add cleanup, if user identified with session token hasn't interacted
with the game in 24h
Store the the previously separate AlbumState inside PlayerState as well.
This means it's better connected to the actual player and will also be
cleaned up
2025-02-12 00:01:51 +02:00
Mihkel Martin Kasterpalu
94b659b1f0 Add a rate limiter 2025-02-11 23:34:57 +02:00
Mihkel Martin Kasterpalu
0526d0029b Fix pakubiiti import bug 2025-02-11 21:06:20 +02:00
Mihkel Martin Kasterpalu
1979206a9d Rahvatarkus submit button to center 2025-02-11 20:19:42 +02:00
Mihkel Martin Kasterpalu
f26aa8f5c7 Rahvatarkus Better UI and errors
Handle more edgecases
More specific error messages
All error messages in Estonian
2025-02-11 20:19:42 +02:00
Mihkel Martin Kasterpalu
35ad2da1c3 Rahvatarkus better ui 2025-02-11 20:19:42 +02:00
38 changed files with 1372 additions and 520 deletions

View file

@ -1,4 +1,16 @@
# 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=""

View file

@ -57,9 +57,14 @@
"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
View file

@ -14,6 +14,18 @@ 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
@ -23,6 +35,9 @@ 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
@ -148,6 +163,9 @@ 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'}
@ -950,6 +968,11 @@ 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]
@ -1125,6 +1148,18 @@ 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'}
@ -1151,6 +1186,12 @@ 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'}
@ -1334,6 +1375,9 @@ 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'}
@ -2302,8 +2346,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.2: runed@0.23.3:
resolution: {integrity: sha512-AhHCb5/B+YQW6ar1pzhGQOQy+byfjCH63ofuhrexSWwQKhC0EbQ60Z/wMYwETLo3ZubhwlNryxBt0seOMOrVFQ==} resolution: {integrity: sha512-qmL6JOvI9fg2XrSI9eP8bVIaAyk1ztVZsoj37hTs4BSuOOyeLkrIPI16mwarXFYbxSfyJGCwAWgfpSq+ehQmgg==}
peerDependencies: peerDependencies:
svelte: ^5.7.0 svelte: ^5.7.0
@ -2724,6 +2768,8 @@ 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
@ -3268,6 +3314,9 @@ 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
@ -3485,6 +3534,19 @@ 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
@ -3517,6 +3579,14 @@ 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: {}
@ -3581,7 +3651,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.2(svelte@5.19.1) runed: 0.23.3(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
@ -3708,6 +3778,8 @@ 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:
@ -4556,7 +4628,7 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
runed@0.23.2(svelte@5.19.1): runed@0.23.3(svelte@5.19.1):
dependencies: dependencies:
esm-env: 1.2.2 esm-env: 1.2.2
svelte: 5.19.1 svelte: 5.19.1
@ -4782,7 +4854,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.2(svelte@5.19.1) runed: 0.23.3(svelte@5.19.1)
style-to-object: 1.0.8 style-to-object: 1.0.8
svelte: 5.19.1 svelte: 5.19.1

View file

@ -32,6 +32,16 @@
--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 {
@ -51,7 +61,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 62.8% 30.6%; --destructive: 0 70% 63.9%;
--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%;
@ -62,6 +72,13 @@
--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;
} }
} }

View file

@ -0,0 +1,41 @@
<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>

View file

@ -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/AlbumClientState.svelte'; import { getAlbumClientState } from '$lib/client/pakubiiti/AlbumClientState.svelte';
let { items, image = false, type = 'default' } = $props(); let { items, image = false, type = 'default' } = $props();

View file

@ -0,0 +1,18 @@
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,
};

View file

@ -0,0 +1,21 @@
<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}
/>

View file

@ -0,0 +1,19 @@
<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}
/>

View file

@ -0,0 +1,21 @@
<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}
/>

View file

@ -0,0 +1,28 @@
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,
};

View file

@ -0,0 +1,22 @@
<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>

View file

@ -1,6 +1,11 @@
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: '',
@ -15,11 +20,6 @@ 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: ''
} }
}; };

View file

@ -1,4 +1,4 @@
import type { Project } from '$lib/types'; import { ImageCreditType, 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: 'instagram', type: ImageCreditType.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: 'instagram', type: ImageCreditType.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: 'instagram', type: ImageCreditType.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: 'web', type: ImageCreditType.web,
author: 'treierxyz', author: 'treierxyz',
href: 'https://treier.xyz' href: 'https://treier.xyz'
}, },

View file

@ -1,45 +0,0 @@
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();

View file

@ -1,90 +1,139 @@
import type { Player } from '$lib/types'; import type { AlbumSolveState, 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.players.push({ id: id, stage: 0, highscore: 0, playing: true }); this.updateLastAccessed(id);
} this.players.push({ id: id, stage: 0, highscore: 0, playing: true, albums: [] });
getStage(id: string) {
const player = this.players.find((player) => player.id === id);
if (!player) {
return undefined;
}
return player.stage;
} }
nextStage(id: string) { nextStage(id: string) {
const player = this.players.find((player) => player.id === id); const player = this.findPlayer(id);
if (player) {
if (!player) {
return;
}
player.stage += 1; player.stage += 1;
} }
}
restart(id: string) { restart(id: string) {
const player = this.players.find((player) => player.id === id); const player = this.findPlayer(id);
if (player) {
if (!player) {
return;
}
player.stage = 0; player.stage = 0;
player.playing = true; player.playing = true;
} }
score(id: string, won: boolean) {
const player = this.players.find((player) => player.id === id);
if (!player) {
return;
} }
if (won) { 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; player.stage += 1;
return; return true;
} }
player.playing = false; player.playing = false;
if (player.stage > player.highscore) { if (player.stage > player.highscore) {
player.highscore = player.stage; player.highscore = player.stage;
} }
return true;
} }
getHighscore(id: string) { getHighscore(id: string) {
const player = this.players.find((player) => player.id === id); const player = this.findPlayer(id);
return player?.highscore;
if (!player) {
return undefined;
} }
return player.highscore; getStage(id: string) {
const player = this.findPlayer(id);
return player?.stage;
} }
getPlaying(id: string) { getPlaying(id: string) {
const player = this.players.find((player) => player.id === id); const player = this.findPlayer(id);
return player?.playing;
if (!player) {
return undefined;
}
return player.playing;
} }
setPlaying(id: string, playing: boolean) { setPlaying(id: string, playing: boolean) {
const player = this.players.find((player) => player.id === id); const player = this.findPlayer(id);
if (player) {
if (!player) { player.playing = playing;
return; }
} }
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;
});
} }
} }

View file

@ -0,0 +1,73 @@
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();

View file

@ -0,0 +1,13 @@
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);
};

58
src/lib/server/redis.ts Normal file
View file

@ -0,0 +1,58 @@
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')
})
};

View file

@ -41,6 +41,7 @@ export type Player = {
stage: number; stage: number;
highscore: number; highscore: number;
playing: boolean; playing: boolean;
albums: AlbumSolveState[];
}; };
export interface TimeRemaining { export interface TimeRemaining {
@ -79,3 +80,9 @@ 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;
}

View file

@ -0,0 +1,22 @@
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 });
}

View file

@ -1,7 +1,6 @@
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;
@ -30,12 +29,8 @@ 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 });
} }

View file

@ -1,7 +1,11 @@
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 { eq, sql } from 'drizzle-orm'; import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
import { ratelimit } from '$lib/server/redis';
const maxAnswers = 5; const maxAnswers = 5;
@ -16,6 +20,15 @@ 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
@ -29,17 +42,17 @@ export async function POST({ locals, request }) {
.limit(1); .limit(1);
if (!question.length) { if (!question.length) {
return json({ error: 'Question not found' }, { status: 400 }); return json({ error: 'Seda küsimust ei leitud' }, { status: 400 });
} }
const [questionData] = question; const [questionData] = question;
// if (questionData.creator === user) { if (questionData.creator === user) {
// return json({ error: 'Cannot answer own question' }, { status: 400 }); return json({ error: 'Iseendale vastamine ei ole produktiivne' }, { status: 400 });
// } }
if (questionData.answerCount >= maxAnswers) { if (questionData.answerCount >= maxAnswers) {
return json({ error: 'No more answers needed' }, { status: 400 }); return json({ error: 'Sellel küsimusel on juba piisavalt küsimusi' }, { status: 400 });
} }
// Insert answer and update count atomically // Insert answer and update count atomically
@ -57,6 +70,9 @@ 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);
}); });
} }

View file

@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import getPoolSize from '$lib/server/rahvatarkus/getPoolSize';
export async function GET() {
const size = await getPoolSize();
return json({ size });
}

View file

@ -1,7 +1,11 @@
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 { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm'; import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
import { ratelimit } from '$lib/server/redis';
export async function GET({ locals }) { export async function GET({ locals }) {
const { session } = locals; const { session } = locals;
@ -9,6 +13,15 @@ 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({
@ -19,6 +32,7 @@ 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(
@ -34,7 +48,7 @@ export async function GET({ locals }) {
.limit(1); .limit(1);
if (!eligibleQuestions.length) { if (!eligibleQuestions.length) {
return json({ error: 'No questions available' }, { status: 404 }); return json({ error: '' }, { status: 404 });
} }
return json(eligibleQuestions[0]); return json(eligibleQuestions[0]);
@ -51,8 +65,17 @@ 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: 'Content is required' }, { status: 400 }); return json({ error: 'Küsimustel on sisu vaja' }, { status: 400 });
} }
// Normalize content // Normalize content
@ -71,7 +94,7 @@ export async function POST({ locals, request }) {
.limit(1); .limit(1);
if (existingQuestion.length > 0) { if (existingQuestion.length > 0) {
throw new Error('Question already exists'); throw new Error('Seda on juba küsitud');
} }
// Check user's recent questions (optional rate limiting) // Check user's recent questions (optional rate limiting)
@ -86,7 +109,11 @@ export async function POST({ locals, request }) {
); );
if (recentQuestions[0].count >= 10) { if (recentQuestions[0].count >= 10) {
throw new Error('Too many questions in the last hour'); throw new Error('Rahu rahu! Oled tunni aja jooksul liiga palju küsimusi esitanud');
}
if (!questionBalanceStore.useQuestion(user)) {
throw new Error('Pead vastama teistele enne küsimist');
} }
// Insert the new question // Insert the new question
@ -103,7 +130,7 @@ export async function POST({ locals, request }) {
return json(newQuestion); return json(newQuestion);
} catch (e) { } catch (e) {
const error = e instanceof Error ? e.message : 'Failed to create question'; const error = e instanceof Error ? e.message : 'Küsimine põrus';
return json({ error }, { status: 400 }); return json({ error }, { status: 400 });
} }
} }

View file

@ -30,10 +30,7 @@
> >
{#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 {href === 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"
''
? 'pressed pointer-events-none'
: ''}"
style="background-image: url('{image}')" style="background-image: url('{image}')"
draggable="false" draggable="false"
href="/vinge/{href}" href="/vinge/{href}"

View file

@ -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 ({ fetch, locals }) => { export const load: PageServerLoad = async (event) => {
const { session } = locals; const { session } = event.locals;
if (!session?.data?.userId) { if (!session?.data?.userId) {
await session.setData({ userId: nanoid() }); await session.setData({ userId: nanoid() });
@ -35,7 +35,45 @@ export const load: PageServerLoad = async ({ fetch, locals }) => {
}; };
} }
const albumData = fetch(`/api/pakubiiti/getAlbums/${count}`) 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.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();
}) })
@ -53,6 +91,8 @@ export const load: PageServerLoad = async ({ fetch, locals }) => {
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),
@ -94,9 +134,7 @@ export const actions = {
state.push({ name: name, image: image, artists: artists }); state.push({ name: name, image: image, artists: artists });
} }
const solved = albumState.checkSolve(state); const solved = playerState.score(user, state);
playerState.score(user, solved);
return { solved: solved }; return { solved: solved };
}, },

View file

@ -64,29 +64,35 @@
{/if} {/if}
{/snippet} {/snippet}
<AlertDialog.Root open={data.playing === false}> <AlertDialog.Root open={data.playing === false || data.error}>
<AlertDialog.Content> <AlertDialog.Content>
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title> <AlertDialog.Title>
{#if data?.highscore && data?.stage && data.highscore === data.stage} {#if data.error}
{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.stage === 0} {#if data.error}
{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>

View file

@ -6,6 +6,7 @@ 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;
@ -17,6 +18,19 @@ 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();
@ -26,15 +40,18 @@ 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

View file

@ -1,13 +1,30 @@
<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();
$inspect(data); let firstTab = $state('answer');
onMount(() => {
firstTab = data?.question ? 'answer' : 'question';
});
</script> </script>
<QuestionForm {data} /> <Tabs.Root bind:value={firstTab} class="flex w-full max-w-md flex-col items-center gap-1">
<AnswerForm {data} /> <Tabs.List class="grid w-full grid-cols-2">
<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()}

View file

@ -5,11 +5,12 @@ 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')) || 0; const page = Number(url.searchParams.get('leht')) || 1;
const archiveRes = fetch(`/api/rahvatarkus/archive/${pageSize}/${(page - 1) * pageSize}`) const archiveRes = fetch(`/api/rahvatarkus/archive/${pageSize}/${(page - 1) * pageSize}`)
.then((res) => { .then((res) => {
@ -45,6 +46,70 @@ 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',
@ -63,12 +128,12 @@ export const actions: Actions = {
}); });
if (!response.ok) { if (!response.ok) {
const errorMessage = response.data?.error; if (response.data?.error) {
if (form.errors.answer) { if (form.errors.answer) {
form.errors.answer.push(errorMessage); form.errors.answer.push(response.data.error);
} else { } else {
form.errors.answer = [errorMessage]; form.errors.answer = [response.data.error];
}
} }
return fail(400, { return fail(400, {
@ -97,6 +162,70 @@ 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',
@ -111,13 +240,11 @@ export const actions: Actions = {
}); });
if (!response.ok) { if (!response.ok) {
if (response.data?.error?.code === 'SQLITE_CONSTRAINT_UNIQUE') { if (response.data?.error) {
const errorMessage = 'Sellel küsimusel on juba vastus.';
if (form.errors.question) { if (form.errors.question) {
form.errors.question.push(errorMessage); form.errors.question.push(response.data.error);
} else { } else {
form.errors.question = [errorMessage]; form.errors.question = [response.data.error];
} }
} }

View file

@ -1,31 +1,49 @@
<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();
$inspect(data); const isDesktop = new MediaQuery('(min-width: 768px)');
const siblingCount = $derived(isDesktop.current ? 1 : 0);
</script> </script>
{#await data.streamed.archive} <header class="mb-12 mt-24 flex flex-col items-center text-center font-title">
<p>loading</p> <h1 class="mb-1 scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
{:then archive} Mida rahvas teab?
<Accordion.Root type="multiple" class="w-2/3 space-y-6"> </h1>
</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}
<li> <blockquote
class="border-l-2 bg-muted/25 pl-4 italic leading-7 [&:not(:first-child)]:mt-3"
>
{answer.content} {answer.content}
</li> </blockquote>
{/each} {/each}
</ol>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
{/each} {/each}
@ -37,12 +55,19 @@
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> <Pagination.Content class="flex items-center">
<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>
@ -56,10 +81,16 @@
</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>

View file

@ -6,37 +6,71 @@
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 { data }: { data: { answer_form: SuperValidated<Infer<FormSchema>>; question: Question } } = let {
$props(); data
}: {
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(`You submitted ${JSON.stringify(f.data, null, 2)}`); toast.success('Vastus saadetud.');
} else { } else {
toast.error('Please fix the errors in the form.'); toast.error('Vastamine nurjus, palun paranda vead.');
} }
} }
}); });
const { form: formData, enhance } = form; const { form: formData, enhance, constraints } = form;
</script> </script>
{#if data.question} <Card.Root>
<form method="POST" class="w-2/3 space-y-6" use:enhance action="?/answer"> <Card.Header>
{#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>{data.question.content}?</Form.Label> <Form.Label class="transition-colors">{data.question.content}?</Form.Label>
<Input {...props} bind:value={$formData.answer} /> <Textarea
{...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>
@ -44,8 +78,18 @@
<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>

View file

@ -2,7 +2,11 @@ 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.string().min(2).max(250) answer: z
.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;

View file

@ -1,41 +1,83 @@
<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 { data }: { data: { question_form: SuperValidated<Infer<FormSchema>> } } = $props(); let {
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(`You submitted ${JSON.stringify(f.data, null, 2)}`); toast.success('Küsimus esitatud.');
} else { } else {
toast.error('Please fix the errors in the form.'); toast.error('Küsimine nurjus, palun paranda vead.');
} }
} }
}); });
const { form: formData, enhance } = form; const { form: formData, enhance, constraints } = form;
</script> </script>
<form method="POST" class="w-2/3 space-y-6" use:enhance action="?/question"> <Card.Root>
<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>Uus küsimus</Form.Label> <Form.Label class="transition-colors">Küsimus rahvale</Form.Label>
<Input {...props} bind:value={$formData.question} /> <Input {...props} bind:value={$formData.question} class="transition-colors" />
{/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>
</form> </Card.Footer>
</form>
{/if}
</Card.Root>

View file

@ -1,7 +1,11 @@
import { z } from 'zod'; import { z } from 'zod';
export const formSchema = z.object({ export const formSchema = z.object({
question: z.string().min(2).max(50) question: z
.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;

View file

@ -1,4 +1,6 @@
<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';
@ -11,218 +13,11 @@
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 SevenSegmentDigit from './SevenSegmentDigit.svelte'; import { watch } from 'runed';
import { getTimeRemaining } from '$lib/utils'; import { getTimeRemaining } from '$lib/utils';
import roomImg from '$lib/assets/vaukuivali/roomtone.jpg?enhanced'; import SevenSegmentDigit from './SevenSegmentDigit.svelte';
import watchImg from '$lib/assets/vaukuivali/oldwatch.jpg?enhanced'; import { soundCheckpoints } from './checkpoints';
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) {
@ -235,11 +30,11 @@
} }
// Source: Claude 3.5 Sonnet // Source: Claude 3.5 Sonnet
function getCurrentCheckpoint(arr: SoundCheckpoint[], current: number) { function getCurrentCheckpoint(arr: number[], current: number) {
return ( return (
arr.reduce( arr.reduce(
(prev: SoundCheckpoint | undefined, item) => (prev: SoundCheckpoint | undefined, item) =>
item.db <= current && (!prev || item.db > prev.db) ? item : prev, item <= current && (!prev || item > prev) ? item : prev,
undefined undefined
) || arr.at(0) ) || arr.at(0)
); );
@ -260,7 +55,9 @@
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);
@ -270,7 +67,10 @@
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));
@ -291,19 +91,64 @@
} }
} }
$effect(() => { watch.pre(
if ( () => currentCheckpoint,
currentCheckpoint?.title && (curr, prev) => {
currentCheckpoint != prevCheckpoint && if (curr === prev) return;
!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(() => {
soundCheckpoints[0].crossedTime = new Date(); scrollY.set(0, { duration: 0 });
scrollY.target = 0;
startTime = new Date();
}); });
</script> </script>
@ -319,11 +164,11 @@
</div> </div>
{/snippet} {/snippet}
{#snippet timeCard(point: SoundCheckpoint)} {#snippet timeCard(db: number | undefined)}
<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>{point.db}dBA</strong> - <strong>{db}dBA</strong> -
<span>{getElapsedTime(soundCheckpoints.at(0)?.crossedTime, point.crossedTime)}</span> <span>{getElapsedTime(firstScroll, db ? checkpointTimes[db] : firstScroll)}</span>
</p> </p>
</div> </div>
{/snippet} {/snippet}
@ -365,7 +210,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 soundCheckpoints as { db }} {#each checkpointDecibels 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)"
@ -384,18 +229,21 @@
<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={currentCheckpoint?.image} class="aspect-square object-cover " /> <Image
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?.db === 0} {#if currentCheckpoint === 0}
Vau kui vali! Vau kui vali!
{:else} {:else}
{currentCheckpoint?.title} {soundCheckpoints[currentCheckpoint]?.title}
{/if} {/if}
</h1> </h1>
{#if currentCheckpoint?.db === 0} {#if currentCheckpoint === 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.
@ -408,7 +256,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">
{currentCheckpoint?.description} {soundCheckpoints[currentCheckpoint]?.description}
</p> </p>
{/if} {/if}
</header> </header>
@ -428,10 +276,10 @@
{/snippet} {/snippet}
</Collapsible.Trigger> </Collapsible.Trigger>
</div> </div>
{@render timeCard(soundCheckpoints.at(-1) as SoundCheckpoint)} {@render timeCard(checkpointDecibels.at(-1))}
<Collapsible.Content class="space-y-2"> <Collapsible.Content class="space-y-2">
{#each soundCheckpoints.slice(1, -1).reverse() as point} {#each checkpointDecibels.slice(1, -1).reverse() as db}
{@render timeCard(point)} {@render timeCard(db)}
{/each} {/each}
</Collapsible.Content> </Collapsible.Content>
</Collapsible.Root> </Collapsible.Root>
@ -448,7 +296,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>

View file

@ -0,0 +1,178 @@
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.'
}
}
};