Rahvatarkus Better UI and errors
Handle more edgecases More specific error messages All error messages in Estonian
This commit is contained in:
parent
35ad2da1c3
commit
f26aa8f5c7
15 changed files with 243 additions and 74 deletions
28
src/lib/components/ui/textarea/index.ts
Normal file
28
src/lib/components/ui/textarea/index.ts
Normal 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,
|
||||
};
|
22
src/lib/components/ui/textarea/textarea.svelte
Normal file
22
src/lib/components/ui/textarea/textarea.svelte
Normal 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>
|
73
src/lib/server/rahvatarkus/QuestionBalance.ts
Normal file
73
src/lib/server/rahvatarkus/QuestionBalance.ts
Normal 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();
|
13
src/lib/server/rahvatarkus/getPoolSize.ts
Normal file
13
src/lib/server/rahvatarkus/getPoolSize.ts
Normal 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);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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';
|
||||
});
|
||||
</script>
|
||||
|
||||
<Tabs.Root value={firstTab} class="flex w-full max-w-md flex-col items-center gap-2">
|
||||
<Tabs.Root 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>
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
const siblingCount = $derived(isDesktop.current ? 1 : 0);
|
||||
</script>
|
||||
|
||||
<header class="mb-12 mt-32 flex flex-col items-center text-center font-title">
|
||||
<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>
|
||||
|
|
|
@ -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;
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Vasta vajajale</Card.Title>
|
||||
<Card.Description>
|
||||
{#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}
|
||||
</Card.Description>
|
||||
{#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 meil mingi küsimus?</p>
|
||||
<p class="text-sm leading-6">Äkki tahaksid su sõbrad vastata või on neil küsimusi?</p>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
{:else}
|
||||
|
@ -61,7 +57,7 @@
|
|||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{data.question.content}?</Form.Label>
|
||||
<Input {...props} bind:value={$formData.answer} />
|
||||
<Textarea {...props} bind:value={$formData.answer} class="resize-none" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
|
@ -72,6 +68,9 @@
|
|||
<Input type="hidden" {...props} bind:value={$formData.questionId} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description class="text-right">
|
||||
{$formData.answer.length}/{$constraints.answer?.maxlength}
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</Card.Content>
|
||||
|
|
|
@ -2,7 +2,10 @@ import { z } from 'zod';
|
|||
|
||||
export const formSchema = z.object({
|
||||
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.')
|
||||
});
|
||||
|
||||
export type FormSchema = typeof formSchema;
|
||||
|
|
|
@ -17,12 +17,15 @@
|
|||
question_form: SuperValidated<Infer<FormSchema>>;
|
||||
question: Question;
|
||||
poolSize: number;
|
||||
user: {
|
||||
id: string;
|
||||
balance: number;
|
||||
};
|
||||
};
|
||||
} = $props();
|
||||
|
||||
const form = superForm(data.question_form, {
|
||||
validators: zodClient(formSchema),
|
||||
invalidateAll: false,
|
||||
resetForm: true,
|
||||
onUpdated: ({ form: f }) => {
|
||||
if (f.valid) {
|
||||
|
@ -33,28 +36,40 @@
|
|||
}
|
||||
});
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
const { form: formData, enhance, constraints } = form;
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Küsi rahvalt</Card.Title>
|
||||
<Card.Description>Sul on alles 0 küsimust. Vasta teistele kõigepealt!</Card.Description>
|
||||
<Card.Description>Iga vastus annab võimaluse küsida ühe küsimuse.</Card.Description>
|
||||
</Card.Header>
|
||||
<form method="POST" use:enhance action="?/question">
|
||||
{#if data.user.balance === 0 && (!data.question || data.poolSize > 0)}
|
||||
<Card.Content>
|
||||
<Form.Field {form} name="question">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Küsimus rahvale</Form.Label>
|
||||
<Input {...props} bind:value={$formData.question} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<p class="text-sm leading-6">Enne küsimist pead kõigepealt vastama teistele!</p>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Form.Button>Küsi</Form.Button>
|
||||
</Card.Footer>
|
||||
</form>
|
||||
{:else}
|
||||
<form method="POST" use:enhance action="?/question">
|
||||
<Card.Content>
|
||||
<Form.Field {form} name="question">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Küsimus rahvale</Form.Label>
|
||||
<Input {...props} bind:value={$formData.question} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description class="flex justify-between">
|
||||
<p>
|
||||
Sul on alles {data.user.balance > 0 ? data.user.balance : data.poolSize > 0 ? 0 : 1} küsimust.
|
||||
</p>
|
||||
<p>{$formData.question.length}/{$constraints.question?.maxlength}</p>
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Form.Button>Küsi</Form.Button>
|
||||
</Card.Footer>
|
||||
</form>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
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.')
|
||||
});
|
||||
|
||||
export type FormSchema = typeof formSchema;
|
||||
|
|
Loading…
Add table
Reference in a new issue