diff --git a/src/lib/components/ui/textarea/index.ts b/src/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..6eb6ba3 --- /dev/null +++ b/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,28 @@ +import Root from "./textarea.svelte"; + +type FormTextareaEvent = T & { + currentTarget: EventTarget & HTMLTextAreaElement; +}; + +type TextareaEvents = { + blur: FormTextareaEvent; + change: FormTextareaEvent; + click: FormTextareaEvent; + focus: FormTextareaEvent; + keydown: FormTextareaEvent; + keypress: FormTextareaEvent; + keyup: FormTextareaEvent; + mouseover: FormTextareaEvent; + mouseenter: FormTextareaEvent; + mouseleave: FormTextareaEvent; + paste: FormTextareaEvent; + input: FormTextareaEvent; +}; + +export { + Root, + // + Root as Textarea, + type TextareaEvents, + type FormTextareaEvent, +}; diff --git a/src/lib/components/ui/textarea/textarea.svelte b/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..b9c6ca8 --- /dev/null +++ b/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/lib/server/rahvatarkus/QuestionBalance.ts b/src/lib/server/rahvatarkus/QuestionBalance.ts new file mode 100644 index 0000000..d30eefb --- /dev/null +++ b/src/lib/server/rahvatarkus/QuestionBalance.ts @@ -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; + private lastAccessed: Map; + + 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 { + 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(); diff --git a/src/lib/server/rahvatarkus/getPoolSize.ts b/src/lib/server/rahvatarkus/getPoolSize.ts new file mode 100644 index 0000000..d9da99a --- /dev/null +++ b/src/lib/server/rahvatarkus/getPoolSize.ts @@ -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); +}; diff --git a/src/routes/api/rahvatarkus/answer/+server.ts b/src/routes/api/rahvatarkus/answer/+server.ts index b5cae15..b7fbd7d 100644 --- a/src/routes/api/rahvatarkus/answer/+server.ts +++ b/src/routes/api/rahvatarkus/answer/+server.ts @@ -1,7 +1,10 @@ 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 { eq, sql } from 'drizzle-orm'; +import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance'; const maxAnswers = 5; @@ -29,17 +32,17 @@ export async function POST({ locals, request }) { .limit(1); if (!question.length) { - return json({ error: 'Question not found' }, { status: 400 }); + return json({ error: 'Seda küsimust ei leitud' }, { status: 400 }); } const [questionData] = question; - // if (questionData.creator === user) { - // return json({ error: 'Cannot answer own question' }, { status: 400 }); - // } + if (questionData.creator === user) { + return json({ error: 'Iseendale vastamine ei ole produktiivne' }, { status: 400 }); + } 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 @@ -57,6 +60,9 @@ 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); }); } diff --git a/src/routes/api/rahvatarkus/pool/+server.ts b/src/routes/api/rahvatarkus/pool/+server.ts index 0be893f..acf9f15 100644 --- a/src/routes/api/rahvatarkus/pool/+server.ts +++ b/src/routes/api/rahvatarkus/pool/+server.ts @@ -1,16 +1,8 @@ import { json } from '@sveltejs/kit'; -import { db } from '$lib/server/db'; -import { questions } from '$lib/server/db/schema'; -import { sql } from 'drizzle-orm'; +import getPoolSize from '$lib/server/rahvatarkus/getPoolSize'; export async function GET() { - const results = await db - .select({ - poolSize: sql`COUNT(CASE WHEN answer_count < 5 THEN 1 END)` - }) - .from(questions); + const size = await getPoolSize(); - return json({ - size: Number(results[0].poolSize) - }); + return json({ size }); } diff --git a/src/routes/api/rahvatarkus/question/+server.ts b/src/routes/api/rahvatarkus/question/+server.ts index f90e6b8..ae21324 100644 --- a/src/routes/api/rahvatarkus/question/+server.ts +++ b/src/routes/api/rahvatarkus/question/+server.ts @@ -1,7 +1,10 @@ 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 { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm'; +import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance'; export async function GET({ locals }) { const { session } = locals; @@ -19,6 +22,7 @@ export async function GET({ locals }) { .from(questions) .where( and( + not(eq(questions.creator, user)), lt(questions.answerCount, 5), not( exists( @@ -34,7 +38,7 @@ export async function GET({ locals }) { .limit(1); if (!eligibleQuestions.length) { - return json({ error: 'No questions available' }, { status: 404 }); + return json({ error: '' }, { status: 404 }); } return json(eligibleQuestions[0]); @@ -52,7 +56,7 @@ export async function POST({ locals, request }) { } if (!content?.trim()) { - return json({ error: 'Content is required' }, { status: 400 }); + return json({ error: 'Küsimustel on sisu vaja' }, { status: 400 }); } // Normalize content @@ -71,7 +75,7 @@ export async function POST({ locals, request }) { .limit(1); 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) @@ -86,7 +90,11 @@ export async function POST({ locals, request }) { ); 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 @@ -103,7 +111,7 @@ export async function POST({ locals, request }) { return json(newQuestion); } 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 }); } } diff --git a/src/routes/vinge/rahvatarkus/+layout.server.ts b/src/routes/vinge/rahvatarkus/+layout.server.ts index 249dd79..2b46fd1 100644 --- a/src/routes/vinge/rahvatarkus/+layout.server.ts +++ b/src/routes/vinge/rahvatarkus/+layout.server.ts @@ -6,6 +6,7 @@ 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; @@ -17,6 +18,8 @@ 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') @@ -43,7 +46,10 @@ export const load: LayoutServerLoad = async ({ fetch, locals }) => { } return { - user: user, + user: { + id: user, + balance: userBalance + }, question: question, poolSize, question_form: await superValidate(zod(questionSchema)), diff --git a/src/routes/vinge/rahvatarkus/+layout.svelte b/src/routes/vinge/rahvatarkus/+layout.svelte index 87c2d5b..dc9ade7 100644 --- a/src/routes/vinge/rahvatarkus/+layout.svelte +++ b/src/routes/vinge/rahvatarkus/+layout.svelte @@ -3,15 +3,18 @@ import QuestionForm from './question-form.svelte'; import AnswerForm from './answer-form.svelte'; + import { onMount } from 'svelte'; let { data, children } = $props(); - $inspect(data); + let firstTab = $state('answer'); - let firstTab = $derived(data?.question ? 'answer' : 'question'); + onMount(() => { + firstTab = data?.question ? 'answer' : 'question'; + }); - + Vasta Küsi diff --git a/src/routes/vinge/rahvatarkus/+page.server.ts b/src/routes/vinge/rahvatarkus/+page.server.ts index 7b0cabe..d0dea83 100644 --- a/src/routes/vinge/rahvatarkus/+page.server.ts +++ b/src/routes/vinge/rahvatarkus/+page.server.ts @@ -9,7 +9,7 @@ import { fail } from '@sveltejs/kit'; const pageSize = 5; 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}`) .then((res) => { @@ -63,12 +63,12 @@ export const actions: Actions = { }); if (!response.ok) { - const errorMessage = response.data?.error; - - if (form.errors.answer) { - form.errors.answer.push(errorMessage); - } else { - form.errors.answer = [errorMessage]; + if (response.data?.error) { + if (form.errors.answer) { + form.errors.answer.push(response.data.error); + } else { + form.errors.answer = [response.data.error]; + } } return fail(400, { @@ -111,13 +111,11 @@ export const actions: Actions = { }); if (!response.ok) { - if (response.data?.error?.code === 'SQLITE_CONSTRAINT_UNIQUE') { - const errorMessage = 'Sellel küsimusel on juba vastus.'; - + if (response.data?.error) { if (form.errors.question) { - form.errors.question.push(errorMessage); + form.errors.question.push(response.data.error); } else { - form.errors.question = [errorMessage]; + form.errors.question = [response.data.error]; } } diff --git a/src/routes/vinge/rahvatarkus/+page.svelte b/src/routes/vinge/rahvatarkus/+page.svelte index 711e636..e728518 100644 --- a/src/routes/vinge/rahvatarkus/+page.svelte +++ b/src/routes/vinge/rahvatarkus/+page.svelte @@ -16,7 +16,7 @@ const siblingCount = $derived(isDesktop.current ? 1 : 0); -
+

Mida rahvas teab?

diff --git a/src/routes/vinge/rahvatarkus/answer-form.svelte b/src/routes/vinge/rahvatarkus/answer-form.svelte index 499f67e..ef08d99 100644 --- a/src/routes/vinge/rahvatarkus/answer-form.svelte +++ b/src/routes/vinge/rahvatarkus/answer-form.svelte @@ -9,6 +9,7 @@ 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'; let { data @@ -28,30 +29,25 @@ } }); - const { form: formData, enhance } = form; + const { form: formData, enhance, constraints } = form; - Vasta vajajale - - {#if !data.question} - {#if data.poolSize === 0} - Rahval said kõik küsimused otsa! - {:else} - Oled kõigile vastanud, kuid enda küsimustel vastused puudu. - {/if} - {:else} - Tänutäheks saad vastu ühe küsimuse küsida. - {/if} - + {#if !data.question} + Kõik vastatud + Rahval said kõik küsimused otsa! + {:else} + Vasta vajajale + Tänutäheks saad vastu ühe küsimuse küsida. + {/if} {#if !data.question} {#if data.poolSize === 0}

Sellise erandjuhuga saad ühe korra niisama küsida!

{:else} -

Äkki tahaksid su sõbrad vastata või on meil mingi küsimus?

+

Äkki tahaksid su sõbrad vastata või on neil küsimusi?

{/if}
{:else} @@ -61,7 +57,7 @@ {#snippet children({ props })} {data.question.content}? - +