Compare commits

..

No commits in common. "c6ec335b04ed8c7a162295cf5ddcbc162e0d93d1" and "6847b6605c0e17cfe5d80e0ff10e9333d77f144d" have entirely different histories.

38 changed files with 544 additions and 1396 deletions

View file

@ -1,16 +1,4 @@
# Spotify API
CLIENT_ID=<spotifyAPIID>
CLIENT_SECRET=<spotifyAPISecret>
# Session token salt
SESH_SECRET=<longRandomString>
# Secret used to generate ALTCHA captchas
ALTCHA_HMAC=<longRandomString>
# SQLite DB location
DATABASE_URL=local.db
# Rate limit Redis instance on upstash
UPSTASH_REDIS_URL=""
UPSTASH_REDIS_TOKEN=""

View file

@ -57,14 +57,9 @@
"dependencies": {
"@fontsource-variable/kode-mono": "^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",
"drizzle-orm": "^0.38.4",
"nanoid": "^5.0.9",
"runed": "^0.23.3",
"spotify-web-api-node": "^5.0.2",
"svelte-kit-sessions": "^0.4.0"
},

82
pnpm-lock.yaml generated
View file

@ -14,18 +14,6 @@ importers:
'@fontsource-variable/smooch-sans':
specifier: ^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:
specifier: ^11.8.0
version: 11.8.1
@ -35,9 +23,6 @@ importers:
nanoid:
specifier: ^5.0.9
version: 5.0.9
runed:
specifier: ^0.23.3
version: 0.23.3(svelte@5.19.1)
spotify-web-api-node:
specifier: ^5.0.2
version: 5.0.2
@ -163,9 +148,6 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@altcha/crypto@0.0.1':
resolution: {integrity: sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==}
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
@ -968,11 +950,6 @@ packages:
cpu: [s390x]
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':
resolution: {integrity: sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==}
cpu: [x64]
@ -1148,18 +1125,6 @@ packages:
resolution: {integrity: sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==}
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':
resolution: {integrity: sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==}
engines: {node: '>=18.0.0'}
@ -1186,12 +1151,6 @@ packages:
ajv@6.12.6:
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:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -1375,9 +1334,6 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@ -2346,8 +2302,8 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
runed@0.23.3:
resolution: {integrity: sha512-qmL6JOvI9fg2XrSI9eP8bVIaAyk1ztVZsoj37hTs4BSuOOyeLkrIPI16mwarXFYbxSfyJGCwAWgfpSq+ehQmgg==}
runed@0.23.2:
resolution: {integrity: sha512-AhHCb5/B+YQW6ar1pzhGQOQy+byfjCH63ofuhrexSWwQKhC0EbQ60Z/wMYwETLo3ZubhwlNryxBt0seOMOrVFQ==}
peerDependencies:
svelte: ^5.7.0
@ -2768,8 +2724,6 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@altcha/crypto@0.0.1': {}
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
@ -3314,9 +3268,6 @@ snapshots:
'@rollup/rollup-linux-s390x-gnu@4.31.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.18.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.31.0':
optional: true
@ -3534,19 +3485,6 @@ snapshots:
'@typescript-eslint/types': 8.22.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':
optional: true
@ -3579,14 +3517,6 @@ snapshots:
json-schema-traverse: 0.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@6.1.0: {}
@ -3651,7 +3581,7 @@ snapshots:
'@floating-ui/dom': 1.6.13
'@internationalized/date': 3.7.0
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-toolbelt: 0.7.1(svelte@5.19.1)
tabbable: 6.2.0
@ -3778,8 +3708,6 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
crypto-js@4.2.0: {}
cssesc@3.0.0: {}
dayjs@1.11.13:
@ -4628,7 +4556,7 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
runed@0.23.3(svelte@5.19.1):
runed@0.23.2(svelte@5.19.1):
dependencies:
esm-env: 1.2.2
svelte: 5.19.1
@ -4854,7 +4782,7 @@ snapshots:
svelte-toolbelt@0.7.1(svelte@5.19.1):
dependencies:
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
svelte: 5.19.1

View file

@ -32,16 +32,6 @@
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--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 {
@ -61,7 +51,7 @@
--secondary-foreground: 60 9.1% 94%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 94%;
--destructive: 0 70% 63.9%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 94%;
--ring: 24 5.7% 82.9%;
--sidebar-background: 240 5.9% 10%;
@ -72,13 +62,6 @@
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--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

@ -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>

View file

@ -15,7 +15,7 @@
import * as Card from '$lib/components/ui/card/index.js';
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();

View file

@ -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,
};

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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,
};

View file

@ -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>

View file

@ -1,11 +1,6 @@
import type { GamesObj } from '$lib/types';
const games: GamesObj = {
rahvatarkus: {
name: 'Rahva tarkus',
image: '',
description: 'Rahvas teab, sest pitu pead on mitu pead - hajusintelligents'
},
vaukuivali: {
name: 'Vau kui vali',
image: '',
@ -20,6 +15,11 @@ const games: GamesObj = {
name: 'Paku biiti',
image: '',
description: 'Sorteeri kolme suvalise muusika albumi pealkiri, artistid ja pilt.'
},
'': {
name: 'Rohkem mänge soon™',
image: '',
description: ''
}
};

View file

@ -1,4 +1,4 @@
import { ImageCreditType, type Project } from '$lib/types';
import type { Project } from '$lib/types';
import badges from './badges';
import skpImg from '$lib/assets/skp.jpg?enhanced';
@ -12,7 +12,7 @@ const projects: Project[] = [
image: {
src: skpImg,
credit: {
type: ImageCreditType.instagram,
type: 'instagram',
author: 'Mimmu',
href: 'https://www.instagram.com/musamimmu/'
},
@ -27,7 +27,7 @@ const projects: Project[] = [
image: {
src: dysasterImg,
credit: {
type: ImageCreditType.instagram,
type: 'instagram',
author: 'Mattias Mägi',
href: 'https://www.instagram.com/mattias.mix/'
},
@ -42,7 +42,7 @@ const projects: Project[] = [
image: {
src: monospaceeImg,
credit: {
type: ImageCreditType.instagram,
type: 'instagram',
author: 'Liisa Jõhvik',
href: 'https://www.instagram.com/liisajohvik.photo/'
},
@ -67,7 +67,7 @@ const projects: Project[] = [
image: {
src: '/assets/hakkerikoda.svg',
credit: {
type: ImageCreditType.web,
type: 'web',
author: 'treierxyz',
href: 'https://treier.xyz'
},

View 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();

View file

@ -1,140 +1,91 @@
import type { AlbumSolveState, Player } from '$lib/types';
import type { Player } from '$lib/types';
export class PlayerState {
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) {
this.updateLastAccessed(id);
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;
this.players.push({ id: id, stage: 0, highscore: 0, playing: true });
}
getStage(id: string) {
const player = this.findPlayer(id);
return player?.stage;
const player = this.players.find((player) => player.id === id);
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) {
const player = this.findPlayer(id);
return player?.playing;
const player = this.players.find((player) => player.id === id);
if (!player) {
return undefined;
}
return player.playing;
}
setPlaying(id: string, playing: boolean) {
const player = this.findPlayer(id);
if (player) {
const player = this.players.find((player) => player.id === id);
if (!player) {
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;
});
}
}
export const playerState = new PlayerState();

View file

@ -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();

View file

@ -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);
};

View file

@ -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')
})
};

View file

@ -41,7 +41,6 @@ export type Player = {
stage: number;
highscore: number;
playing: boolean;
albums: AlbumSolveState[];
};
export interface TimeRemaining {
@ -80,9 +79,3 @@ export type TagsObj = Record<string, Tag>;
export type Answer = typeof answers.$inferSelect;
export type Question = typeof questions.$inferSelect & { answers: Answer[] };
export interface SoundCheckpoint {
title: string;
description: string;
image: EnhancedImage | undefined;
}

View file

@ -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 });
}

View file

@ -1,6 +1,7 @@
import type { AlbumSolveState } from '$lib/types';
import { error, json } from '@sveltejs/kit';
import { albumState } from '$lib/server/pakubiiti/AlbumState.svelte';
import { spotifyAPI } from '$lib/server/pakubiiti/Spotify.svelte';
const maxTries = 10;
@ -29,8 +30,12 @@ export async function GET({ params }) {
}
if (albums.length !== count) {
albumState.setAlbums([]);
return error(500, "Couldn't get albums from Spotify.");
}
albumState.setAlbums(albums);
return json({ albums: albums });
}

View file

@ -1,11 +1,7 @@
import { json } from '@sveltejs/kit';
import { eq, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { questions, answers } from '$lib/server/db/schema';
import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
import { ratelimit } from '$lib/server/redis';
import { eq, sql } from 'drizzle-orm';
const maxAnswers = 5;
@ -20,15 +16,6 @@ export async function POST({ locals, request }) {
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
return await db.transaction(async (tx) => {
// Get question and validate in one query
@ -42,17 +29,17 @@ export async function POST({ locals, request }) {
.limit(1);
if (!question.length) {
return json({ error: 'Seda küsimust ei leitud' }, { status: 400 });
return json({ error: 'Question not found' }, { status: 400 });
}
const [questionData] = question;
if (questionData.creator === user) {
return json({ error: 'Iseendale vastamine ei ole produktiivne' }, { status: 400 });
}
// if (questionData.creator === user) {
// return json({ error: 'Cannot answer own question' }, { status: 400 });
// }
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
@ -70,9 +57,6 @@ export async function POST({ locals, request }) {
.set({ answerCount: sql`${questions.answerCount} + 1` })
.where(eq(questions.id, questionId));
// Valid answer, allow this user to ask one question
questionBalanceStore.addQuestions(user);
return json(newAnswer);
});
}

View file

@ -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 });
}

View file

@ -1,11 +1,7 @@
import { json } from '@sveltejs/kit';
import { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { questions, answers } from '$lib/server/db/schema';
import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
import { ratelimit } from '$lib/server/redis';
import { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm';
export async function GET({ locals }) {
const { session } = locals;
@ -13,15 +9,6 @@ export async function GET({ locals }) {
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
const eligibleQuestions = await db
.select({
@ -32,7 +19,6 @@ export async function GET({ locals }) {
.from(questions)
.where(
and(
not(eq(questions.creator, user)),
lt(questions.answerCount, 5),
not(
exists(
@ -48,7 +34,7 @@ export async function GET({ locals }) {
.limit(1);
if (!eligibleQuestions.length) {
return json({ error: '' }, { status: 404 });
return json({ error: 'No questions available' }, { status: 404 });
}
return json(eligibleQuestions[0]);
@ -65,17 +51,8 @@ export async function POST({ locals, request }) {
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()) {
return json({ error: 'Küsimustel on sisu vaja' }, { status: 400 });
return json({ error: 'Content is required' }, { status: 400 });
}
// Normalize content
@ -94,7 +71,7 @@ export async function POST({ locals, request }) {
.limit(1);
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)
@ -109,11 +86,7 @@ export async function POST({ locals, request }) {
);
if (recentQuestions[0].count >= 10) {
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');
throw new Error('Too many questions in the last hour');
}
// Insert the new question
@ -130,7 +103,7 @@ export async function POST({ locals, request }) {
return json(newQuestion);
} 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 });
}
}

View file

@ -30,7 +30,10 @@
>
{#each Object.entries(games) as [href, { image, name }]}
<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}')"
draggable="false"
href="/vinge/{href}"

View file

@ -4,13 +4,13 @@ import type { AlbumSolveState } from '$lib/types';
import { nanoid } from 'nanoid';
import { shuffleArray } from '$lib/utils';
import { albumState } from '$lib/server/pakubiiti/AlbumState.svelte';
import { playerState } from '$lib/server/pakubiiti/PlayerState.svelte';
import { ratelimit } from '$lib/server/redis';
const count = 3;
export const load: PageServerLoad = async (event) => {
const { session } = event.locals;
export const load: PageServerLoad = async ({ fetch, locals }) => {
const { session } = locals;
if (!session?.data?.userId) {
await session.setData({ userId: nanoid() });
@ -35,45 +35,7 @@ export const load: PageServerLoad = async (event) => {
};
}
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}`)
const albumData = fetch(`/api/pakubiiti/getAlbums/${count}`)
.then((res) => {
return res.json();
})
@ -91,8 +53,6 @@ export const load: PageServerLoad = async (event) => {
value: album.artists
}));
playerState.setAlbums(user, data.albums);
return {
names: shuffleArray(albumNames),
images: shuffleArray(albumImages),
@ -134,7 +94,9 @@ export const actions = {
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 };
},

View file

@ -64,35 +64,29 @@
{/if}
{/snippet}
<AlertDialog.Root open={data.playing === false || data.error}>
<AlertDialog.Root open={data.playing === false}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
{#if data.error}
{data.error.title}
{:else if data?.highscore && data?.stage && data.highscore === data.stage}
{#if data?.highscore && data?.stage && data.highscore === data.stage}
Uus parim tulemus!
{:else}
Seekord ei vedanud
{/if}
</AlertDialog.Title>
<AlertDialog.Description>
{#if data.error}
{data.error.message}
{:else if data.stage === 0}
{#if data.stage === 0}
Põrusid esimesel katsel.
{:else}
Vastasid õigesti <strong>{data.stage} korda.</strong>
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
{#if !data.error}
<AlertDialog.Footer>
<form action="?/restart" method="POST" use:enhance>
<AlertDialog.Action type="submit">Uuesti</AlertDialog.Action>
</form>
</AlertDialog.Footer>
{/if}
</AlertDialog.Content>
</AlertDialog.Root>

View file

@ -6,7 +6,6 @@ import { formSchema as answerSchema } from './answer-schema';
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { nanoid } from 'nanoid';
import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
export const load: LayoutServerLoad = async ({ fetch, locals }) => {
const { session } = locals;
@ -18,19 +17,6 @@ export const load: LayoutServerLoad = async ({ fetch, locals }) => {
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')
.then(async (res) => {
const data = await res.json();
@ -40,18 +26,15 @@ export const load: LayoutServerLoad = async ({ fetch, locals }) => {
return data;
});
let question: Question | undefined = undefined;
if (res.ok) {
question = res.data;
}
}
return {
user: {
id: user,
balance: userBalance
},
user: user,
question: question,
poolSize,
question_form: await superValidate(zod(questionSchema)),
answer_form: await superValidate({ questionId: question?.id }, zod(answerSchema), {
errors: false

View file

@ -1,30 +1,13 @@
<script lang="ts">
import * as Tabs from '$lib/components/ui/tabs/index.js';
import QuestionForm from './question-form.svelte';
import AnswerForm from './answer-form.svelte';
import { onMount } from 'svelte';
let { data, children } = $props();
let firstTab = $state('answer');
onMount(() => {
firstTab = data?.question ? 'answer' : 'question';
});
$inspect(data);
</script>
<Tabs.Root bind:value={firstTab} class="flex w-full max-w-md flex-col items-center gap-1">
<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>
<QuestionForm {data} />
<AnswerForm {data} />
{@render children()}

View file

@ -5,12 +5,11 @@ import { formSchema as answerSchema } from './answer-schema';
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { ratelimit } from '$lib/server/redis';
const pageSize = 5;
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}`)
.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
.fetch('/api/rahvatarkus/answer', {
method: 'POST',
@ -128,12 +63,12 @@ export const actions: Actions = {
});
if (!response.ok) {
if (response.data?.error) {
const errorMessage = response.data?.error;
if (form.errors.answer) {
form.errors.answer.push(response.data.error);
form.errors.answer.push(errorMessage);
} else {
form.errors.answer = [response.data.error];
}
form.errors.answer = [errorMessage];
}
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
.fetch('/api/rahvatarkus/question', {
method: 'POST',
@ -240,11 +111,13 @@ export const actions: Actions = {
});
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) {
form.errors.question.push(response.data.error);
form.errors.question.push(errorMessage);
} else {
form.errors.question = [response.data.error];
form.errors.question = [errorMessage];
}
}

View file

@ -1,49 +1,31 @@
<script lang="ts">
import type { PageData } from './$types.js';
import { MediaQuery } from 'svelte/reactivity';
import * as Accordion from '$lib/components/ui/accordion/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 ChevronRight from 'lucide-svelte/icons/chevron-right';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
let { data }: { data: PageData } = $props();
const isDesktop = new MediaQuery('(min-width: 768px)');
const siblingCount = $derived(isDesktop.current ? 1 : 0);
$inspect(data);
</script>
<header class="mb-12 mt-24 flex flex-col items-center text-center font-title">
<h1 class="mb-1 scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
Mida rahvas teab?
</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">
{#await data.streamed.archive}
<p>loading</p>
{:then archive}
<Accordion.Root type="multiple" class="w-2/3 space-y-6">
{#each archive.data as question}
<Accordion.Item disabled={!(question.answers?.length > 0)} value={question.id}>
<Accordion.Trigger>{question.content}?</Accordion.Trigger>
<Accordion.Content>
<ol class="ml-6 list-decimal [&>li]:mt-2">
{#each question.answers as answer}
<blockquote
class="border-l-2 bg-muted/25 pl-4 italic leading-7 [&:not(:first-child)]:mt-3"
>
<li>
{answer.content}
</blockquote>
</li>
{/each}
</ol>
</Accordion.Content>
</Accordion.Item>
{/each}
@ -55,19 +37,12 @@
count={archive.meta.total}
perPage={data.pageSize}
page={data.page}
{siblingCount}
class="my-8"
>
{#snippet children({ pages, currentPage })}
<Pagination.Content class="flex items-center">
<Pagination.Content>
<Pagination.Item>
<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.PrevButton />
</Pagination.Item>
<div class="flex items-center sm:gap-2.5">
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
@ -81,16 +56,10 @@
</Pagination.Item>
{/if}
{/each}
</div>
<Pagination.Item>
<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.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
{/await}
</div>
{/await}

View file

@ -6,71 +6,37 @@
import { zodClient } from 'sveltekit-superforms/adapters';
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 { 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; poolSize: number };
} = $props();
let { data }: { data: { answer_form: SuperValidated<Infer<FormSchema>>; question: Question } } =
$props();
const form = superForm(data.answer_form, {
validators: zodClient(formSchema),
invalidateAll: 'force',
onUpdated: ({ form: f }) => {
if (f.valid) {
toast.success('Vastus saadetud.');
toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`);
} 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>
<Card.Root>
<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>
{#if data.question}
<form method="POST" class="w-2/3 space-y-6" use:enhance action="?/answer">
<Form.Field {form} name="answer">
<Form.Control>
{#snippet children({ props })}
<Form.Label class="transition-colors">{data.question.content}?</Form.Label>
<Textarea
{...props}
bind:value={$formData.answer}
class="resize-none transition-colors"
/>
<Form.Label>{data.question.content}?</Form.Label>
<Input {...props} bind:value={$formData.answer} />
{/snippet}
</Form.Control>
<div class="flex justify-between">
<Form.FieldErrors />
<Form.Description>
{$formData.answer.length}/{$constraints.answer?.maxlength}
</Form.Description>
</div>
</Form.Field>
<Form.Field {form} name="questionId">
<Form.Control>
@ -78,18 +44,8 @@
<Input type="hidden" {...props} bind:value={$formData.questionId} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</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>
</Card.Footer>
</form>
{/if}
</Card.Root>
{/if}

View file

@ -2,11 +2,7 @@ import { z } from 'zod';
export const formSchema = z.object({
questionId: z.string().length(21),
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()
answer: z.string().min(2).max(250)
});
export type FormSchema = typeof formSchema;

View file

@ -1,83 +1,41 @@
<script lang="ts">
import { formSchema, type FormSchema } from './question-schema';
import type { Question } from '$lib/types';
import { toast } from 'svelte-sonner';
import { zodClient } from 'sveltekit-superforms/adapters';
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
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 Altcha from '$lib/components/Altcha.svelte';
let {
data
}: {
data: {
question_form: SuperValidated<Infer<FormSchema>>;
question: Question;
poolSize: number;
user: {
id: string;
balance: number;
};
};
} = $props();
let { data }: { data: { question_form: SuperValidated<Infer<FormSchema>> } } = $props();
const form = superForm(data.question_form, {
validators: zodClient(formSchema),
invalidateAll: false,
resetForm: true,
onUpdated: ({ form: f }) => {
if (f.valid) {
toast.success('Küsimus esitatud.');
toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`);
} 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>
<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 method="POST" class="w-2/3 space-y-6" use:enhance action="?/question">
<Form.Field {form} name="question">
<Form.Control>
{#snippet children({ props })}
<Form.Label class="transition-colors">Küsimus rahvale</Form.Label>
<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} />
<Form.Label>Uus küsimus</Form.Label>
<Input {...props} bind:value={$formData.question} />
{/snippet}
</Form.Control>
<Form.Description>Küsi ükskõik mida sellelt kollektiiv intelektilt.</Form.Description>
<Form.FieldErrors />
</Form.Field>
</Card.Content>
<Card.Footer class="justify-center">
<Form.Button>Küsi</Form.Button>
</Card.Footer>
</form>
{/if}
</Card.Root>
</form>

View file

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

View file

@ -1,6 +1,4 @@
<script lang="ts">
import type { SoundCheckpoint } from '$lib/types';
import { onMount } from 'svelte';
import { Tween } from 'svelte/motion';
import { expoOut } from 'svelte/easing';
@ -13,11 +11,218 @@
import ArrowUpToLine from 'lucide-svelte/icons/arrow-up-to-line';
import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down';
import { watch } from 'runed';
import SevenSegmentDigit from './SevenSegmentDigit.svelte';
import { getTimeRemaining } from '$lib/utils';
import SevenSegmentDigit from './SevenSegmentDigit.svelte';
import { soundCheckpoints } from './checkpoints';
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';
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
function scrollToDecibels(scroll: number) {
@ -30,11 +235,11 @@
}
// Source: Claude 3.5 Sonnet
function getCurrentCheckpoint(arr: number[], current: number) {
function getCurrentCheckpoint(arr: SoundCheckpoint[], current: number) {
return (
arr.reduce(
(prev: SoundCheckpoint | undefined, item) =>
item <= current && (!prev || item > prev) ? item : prev,
item.db <= current && (!prev || item.db > prev.db) ? item : prev,
undefined
) || arr.at(0)
);
@ -55,9 +260,7 @@
let innerHeight = $state(0);
let innerWidth = $state(0);
let startTime: Date | undefined = $state();
let firstScroll: Date | undefined = $state();
let prevCheckpoint: SoundCheckpoint | undefined = $state(undefined);
let scrollScale = $derived(innerHeight * 0.1);
let containerHeight = $derived(decibelsToScroll(150) + innerHeight + innerHeight);
@ -67,10 +270,7 @@
let currentDecibel = $derived(scrollToDecibels(scrollY.target));
let currentDecibelTweened = $derived(scrollToDecibels(scrollY.current));
let checkpointDecibels = $derived(Object.keys(soundCheckpoints).map((value) => Number(value)));
let currentCheckpoint = $derived(getCurrentCheckpoint(checkpointDecibels, currentDecibel));
let checkpointTimes: Record<number, Date> = $state({});
let currentCheckpoint = $derived(getCurrentCheckpoint(soundCheckpoints, currentDecibel));
let decibelMeter = $derived.by(() => {
const clampedValue = Math.min(999.99, Math.max(0, currentDecibel));
@ -91,64 +291,19 @@
}
}
watch.pre(
() => currentCheckpoint,
(curr, prev) => {
if (curr === prev) return;
if (checkpointTimes[curr]) return;
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;
$effect(() => {
if (
currentCheckpoint?.title &&
currentCheckpoint != prevCheckpoint &&
!currentCheckpoint?.crossedTime
) {
currentCheckpoint.crossedTime = new Date();
}
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(() => {
scrollY.set(0, { duration: 0 });
startTime = new Date();
soundCheckpoints[0].crossedTime = new Date();
scrollY.target = 0;
});
</script>
@ -164,11 +319,11 @@
</div>
{/snippet}
{#snippet timeCard(db: number | undefined)}
{#snippet timeCard(point: SoundCheckpoint)}
<div class="rounded-md border px-4 py-3 font-mono text-sm">
<p class="leading-7">
<strong>{db}dBA</strong> -
<span>{getElapsedTime(firstScroll, db ? checkpointTimes[db] : firstScroll)}</span>
<strong>{point.db}dBA</strong> -
<span>{getElapsedTime(soundCheckpoints.at(0)?.crossedTime, point.crossedTime)}</span>
</p>
</div>
{/snippet}
@ -210,7 +365,7 @@
<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"
>
{#each checkpointDecibels as db}
{#each soundCheckpoints as { db }}
<div
transition:fade
style="top: calc({(db / 150) * 70}svh - 0.5rem)"
@ -229,21 +384,18 @@
<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]"
>
<Image
image={soundCheckpoints[currentCheckpoint]?.image}
class="aspect-square object-cover "
/>
<Image image={currentCheckpoint?.image} class="aspect-square object-cover " />
<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"
>
<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!
{:else}
{soundCheckpoints[currentCheckpoint]?.title}
{currentCheckpoint?.title}
{/if}
</h1>
{#if currentCheckpoint === 0}
{#if currentCheckpoint?.db === 0}
<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,
kui 50dB.
@ -256,7 +408,7 @@
</p>
{:else}
<p class="max-w-prose text-2xl font-semibold leading-7 text-primary/80">
{soundCheckpoints[currentCheckpoint]?.description}
{currentCheckpoint?.description}
</p>
{/if}
</header>
@ -276,10 +428,10 @@
{/snippet}
</Collapsible.Trigger>
</div>
{@render timeCard(checkpointDecibels.at(-1))}
{@render timeCard(soundCheckpoints.at(-1) as SoundCheckpoint)}
<Collapsible.Content class="space-y-2">
{#each checkpointDecibels.slice(1, -1).reverse() as db}
{@render timeCard(db)}
{#each soundCheckpoints.slice(1, -1).reverse() as point}
{@render timeCard(point)}
{/each}
</Collapsible.Content>
</Collapsible.Root>
@ -296,7 +448,7 @@
<a
href="https://www.chem.purdue.edu/chemsafety/Training/PPETrain/dblevels.htm"
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>
</ul>

View file

@ -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.'
}
}
};