Compare commits
7 commits
0ebb8e04ce
...
6847b6605c
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6847b6605c | ||
![]() |
46287d6984 | ||
![]() |
74cc8a0458 | ||
![]() |
f3ec1ff41d | ||
![]() |
1674cdd726 | ||
![]() |
11ade8fc06 | ||
![]() |
53166cf2ef |
43 changed files with 2528 additions and 38 deletions
|
@ -1,3 +1,4 @@
|
||||||
CLIENT_ID=<spotifyAPIID>
|
CLIENT_ID=<spotifyAPIID>
|
||||||
CLIENT_SECRET=<spotifyAPISecret>
|
CLIENT_SECRET=<spotifyAPISecret>
|
||||||
SESH_SECRET=<longRandomString>
|
SESH_SECRET=<longRandomString>
|
||||||
|
DATABASE_URL=local.db
|
||||||
|
|
14
drizzle.config.ts
Normal file
14
drizzle.config.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/lib/server/db/schema.ts',
|
||||||
|
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL
|
||||||
|
},
|
||||||
|
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
dialect: 'sqlite'
|
||||||
|
});
|
25
package.json
25
package.json
|
@ -10,7 +10,10 @@
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint ."
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.6",
|
"@eslint/compat": "^1.2.6",
|
||||||
|
@ -21,35 +24,49 @@
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/spotify-web-api-node": "^5.0.11",
|
"@types/spotify-web-api-node": "^5.0.11",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bits-ui": "1.0.0-next.86",
|
"bits-ui": "1.0.0-next.94",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-kit": "^0.30.2",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
|
"formsnap": "^2.0.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"lucide-svelte": "^0.474.0",
|
"lucide-svelte": "^0.474.0",
|
||||||
|
"mode-watcher": "^0.5.1",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.10",
|
"prettier-plugin-tailwindcss": "^0.6.10",
|
||||||
"svelte": "^5.19.1",
|
"svelte": "^5.19.1",
|
||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"svelte-dnd-action": "^0.9.55",
|
"svelte-dnd-action": "^0.9.55",
|
||||||
|
"svelte-sonner": "^0.3.28",
|
||||||
|
"sveltekit-superforms": "^2.23.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwind-variants": "^0.3.1",
|
"tailwind-variants": "^0.3.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.22.0",
|
"typescript-eslint": "^8.22.0",
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11",
|
||||||
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/kode-mono": "^5.1.1",
|
"@fontsource-variable/kode-mono": "^5.1.1",
|
||||||
"@fontsource-variable/smooch-sans": "^5.1.1",
|
"@fontsource-variable/smooch-sans": "^5.1.1",
|
||||||
"mode-watcher": "^0.5.1",
|
"better-sqlite3": "^11.8.0",
|
||||||
|
"drizzle-orm": "^0.38.4",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"spotify-web-api-node": "^5.0.2",
|
"spotify-web-api-node": "^5.0.2",
|
||||||
"svelte-kit-sessions": "^0.4.0"
|
"svelte-kit-sessions": "^0.4.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"better-sqlite3"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
1412
pnpm-lock.yaml
generated
1412
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
7
src/lib/components/ui/form/form-button.svelte
Normal file
7
src/lib/components/ui/form/form-button.svelte
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Button from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: Button.Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button.Root type="submit" bind:ref {...restProps} />
|
17
src/lib/components/ui/form/form-description.svelte
Normal file
17
src/lib/components/ui/form/form-description.svelte
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import type { WithoutChild } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.DescriptionProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
class={cn("text-muted-foreground text-[0.8rem]", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
30
src/lib/components/ui/form/form-element-field.svelte
Normal file
30
src/lib/components/ui/form/form-element-field.svelte
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts" module>
|
||||||
|
import type { FormPathLeaves as _FormPathLeaves } from "sveltekit-superforms";
|
||||||
|
type T = Record<string, unknown>;
|
||||||
|
type U = _FormPathLeaves<T>;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" generics="T extends Record<string, unknown>, U extends _FormPathLeaves<T>">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> &
|
||||||
|
FormPrimitive.ElementFieldProps<T, U> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.ElementField {form} {name}>
|
||||||
|
{#snippet children({ constraints, errors, tainted, value })}
|
||||||
|
<div bind:this={ref} class={cn("space-y-2", className)} {...restProps}>
|
||||||
|
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FormPrimitive.ElementField>
|
30
src/lib/components/ui/form/form-field-errors.svelte
Normal file
30
src/lib/components/ui/form/form-field-errors.svelte
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import type { WithoutChild } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
errorClasses,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.FieldErrorsProps> & {
|
||||||
|
errorClasses?: string | undefined | null;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.FieldErrors
|
||||||
|
class={cn("text-destructive text-[0.8rem] font-medium", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ errors, errorProps })}
|
||||||
|
{#if childrenProp}
|
||||||
|
{@render childrenProp({ errors, errorProps })}
|
||||||
|
{:else}
|
||||||
|
{#each errors as error}
|
||||||
|
<div {...errorProps} class={cn(errorClasses)}>{error}</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</FormPrimitive.FieldErrors>
|
30
src/lib/components/ui/form/form-field.svelte
Normal file
30
src/lib/components/ui/form/form-field.svelte
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts" module>
|
||||||
|
import type { FormPath as _FormPath } from "sveltekit-superforms";
|
||||||
|
type T = Record<string, unknown>;
|
||||||
|
type U = _FormPath<T>;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" generics="T extends Record<string, unknown>, U extends _FormPath<T>">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef, WithoutChildren } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: FormPrimitive.FieldProps<T, U> &
|
||||||
|
WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Field {form} {name}>
|
||||||
|
{#snippet children({ constraints, errors, tainted, value })}
|
||||||
|
<div bind:this={ref} class={cn("space-y-2", className)} {...restProps}>
|
||||||
|
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FormPrimitive.Field>
|
21
src/lib/components/ui/form/form-fieldset.svelte
Normal file
21
src/lib/components/ui/form/form-fieldset.svelte
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts" module>
|
||||||
|
import type { FormPath as _FormPath } from "sveltekit-superforms";
|
||||||
|
type T = Record<string, unknown>;
|
||||||
|
type U = _FormPath<T>;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" generics="T extends Record<string, unknown>, U extends _FormPath<T>">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import type { WithoutChild } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Fieldset bind:ref {form} {name} class={cn("space-y-2", className)} {...restProps} />
|
21
src/lib/components/ui/form/form-label.svelte
Normal file
21
src/lib/components/ui/form/form-label.svelte
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { WithoutChild } from "bits-ui";
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
children,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.LabelProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Label {...restProps} bind:ref>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Label {...props} class={cn("data-[fs-error]:text-destructive", className)}>
|
||||||
|
{@render children?.()}
|
||||||
|
</Label>
|
||||||
|
{/snippet}
|
||||||
|
</FormPrimitive.Label>
|
17
src/lib/components/ui/form/form-legend.svelte
Normal file
17
src/lib/components/ui/form/form-legend.svelte
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import type { WithoutChild } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.LegendProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Legend
|
||||||
|
bind:ref
|
||||||
|
{...restProps}
|
||||||
|
class={cn("data-[fs-error]:text-destructive text-sm font-medium leading-none", className)}
|
||||||
|
/>
|
33
src/lib/components/ui/form/index.ts
Normal file
33
src/lib/components/ui/form/index.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import Description from "./form-description.svelte";
|
||||||
|
import Label from "./form-label.svelte";
|
||||||
|
import FieldErrors from "./form-field-errors.svelte";
|
||||||
|
import Field from "./form-field.svelte";
|
||||||
|
import Button from "./form-button.svelte";
|
||||||
|
import Fieldset from "./form-fieldset.svelte";
|
||||||
|
import Legend from "./form-legend.svelte";
|
||||||
|
import ElementField from "./form-element-field.svelte";
|
||||||
|
|
||||||
|
const Control = FormPrimitive.Control as typeof FormPrimitive.Control;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
Control,
|
||||||
|
Label,
|
||||||
|
FieldErrors,
|
||||||
|
Description,
|
||||||
|
Fieldset,
|
||||||
|
Legend,
|
||||||
|
ElementField,
|
||||||
|
Button,
|
||||||
|
//
|
||||||
|
Field as FormField,
|
||||||
|
Control as FormControl,
|
||||||
|
Description as FormDescription,
|
||||||
|
Label as FormLabel,
|
||||||
|
FieldErrors as FormFieldErrors,
|
||||||
|
Fieldset as FormFieldset,
|
||||||
|
Legend as FormLegend,
|
||||||
|
ElementField as FormElementField,
|
||||||
|
Button as FormButton,
|
||||||
|
};
|
7
src/lib/components/ui/input/index.ts
Normal file
7
src/lib/components/ui/input/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input,
|
||||||
|
};
|
22
src/lib/components/ui/input/input.svelte
Normal file
22
src/lib/components/ui/input/input.svelte
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLInputAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import Root from "./label.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Label,
|
||||||
|
};
|
19
src/lib/components/ui/label/label.svelte
Normal file
19
src/lib/components/ui/label/label.svelte
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Label as LabelPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: LabelPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
24
src/lib/components/ui/pagination/index.ts
Normal file
24
src/lib/components/ui/pagination/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import Root from "./pagination.svelte";
|
||||||
|
import Content from "./pagination-content.svelte";
|
||||||
|
import Item from "./pagination-item.svelte";
|
||||||
|
import Link from "./pagination-link.svelte";
|
||||||
|
import PrevButton from "./pagination-prev-button.svelte";
|
||||||
|
import NextButton from "./pagination-next-button.svelte";
|
||||||
|
import Ellipsis from "./pagination-ellipsis.svelte";
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Item,
|
||||||
|
Link,
|
||||||
|
PrevButton,
|
||||||
|
NextButton,
|
||||||
|
Ellipsis,
|
||||||
|
//
|
||||||
|
Root as Pagination,
|
||||||
|
Content as PaginationContent,
|
||||||
|
Item as PaginationItem,
|
||||||
|
Link as PaginationLink,
|
||||||
|
PrevButton as PaginationPrevButton,
|
||||||
|
NextButton as PaginationNextButton,
|
||||||
|
Ellipsis as PaginationEllipsis,
|
||||||
|
};
|
16
src/lib/components/ui/pagination/pagination-content.svelte
Normal file
16
src/lib/components/ui/pagination/pagination-content.svelte
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul bind:this={ref} class={cn("flex flex-row items-center gap-1", className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</ul>
|
22
src/lib/components/ui/pagination/pagination-ellipsis.svelte
Normal file
22
src/lib/components/ui/pagination/pagination-ellipsis.svelte
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Ellipsis from "lucide-svelte/icons/ellipsis";
|
||||||
|
import type { WithElementRef, WithoutChildren } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<Ellipsis class="size-4" />
|
||||||
|
<span class="sr-only">More pages</span>
|
||||||
|
</span>
|
14
src/lib/components/ui/pagination/pagination-item.svelte
Normal file
14
src/lib/components/ui/pagination/pagination-item.svelte
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLLiAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li bind:this={ref} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</li>
|
41
src/lib/components/ui/pagination/pagination-link.svelte
Normal file
41
src/lib/components/ui/pagination/pagination-link.svelte
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination as PaginationPrimitive, type WithoutChild } from "bits-ui";
|
||||||
|
import {
|
||||||
|
type Props as ButtonProps,
|
||||||
|
buttonVariants,
|
||||||
|
} from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type Props = WithoutChild<PaginationPrimitive.PageProps> &
|
||||||
|
ButtonProps & {
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
size = "icon",
|
||||||
|
isActive = false,
|
||||||
|
page,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
{page.value}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PaginationPrimitive.Page
|
||||||
|
{page}
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
children={children || Fallback}
|
||||||
|
/>
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
|
import ChevronRight from "lucide-svelte/icons/chevron-right";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: PaginationPrimitive.NextButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<span>Next</span>
|
||||||
|
<ChevronRight />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PaginationPrimitive.NextButton
|
||||||
|
bind:ref
|
||||||
|
{...restProps}
|
||||||
|
class={cn(buttonVariants({ variant: "ghost", className: "gap-1 pr-2.5" }), className)}
|
||||||
|
children={children || Fallback}
|
||||||
|
/>
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
|
import ChevronLeft from "lucide-svelte/icons/chevron-left";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: PaginationPrimitive.PrevButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<span>Previous</span>
|
||||||
|
<ChevronLeft />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PaginationPrimitive.PrevButton
|
||||||
|
bind:ref
|
||||||
|
{...restProps}
|
||||||
|
class={cn(buttonVariants({ variant: "ghost", className: "gap-1 pl-2.5" }), className)}
|
||||||
|
children={children || Fallback}
|
||||||
|
/>
|
24
src/lib/components/ui/pagination/pagination.svelte
Normal file
24
src/lib/components/ui/pagination/pagination.svelte
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
count = 0,
|
||||||
|
perPage = 10,
|
||||||
|
page = $bindable(1),
|
||||||
|
siblingCount = 1,
|
||||||
|
...restProps
|
||||||
|
}: PaginationPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PaginationPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
class={cn("mx-auto flex w-full flex-col items-center", className)}
|
||||||
|
{count}
|
||||||
|
{perPage}
|
||||||
|
{siblingCount}
|
||||||
|
bind:page
|
||||||
|
{...restProps}
|
||||||
|
/>
|
1
src/lib/components/ui/sonner/index.ts
Normal file
1
src/lib/components/ui/sonner/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default as Toaster } from "./sonner.svelte";
|
20
src/lib/components/ui/sonner/sonner.svelte
Normal file
20
src/lib/components/ui/sonner/sonner.svelte
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
|
||||||
|
import { mode } from "mode-watcher";
|
||||||
|
|
||||||
|
let restProps: SonnerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sonner
|
||||||
|
theme={$mode}
|
||||||
|
class="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classes: {
|
||||||
|
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
6
src/lib/server/db/index.ts
Normal file
6
src/lib/server/db/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
|
const client = new Database(env.DATABASE_URL);
|
||||||
|
export const db = drizzle(client);
|
45
src/lib/server/db/schema.ts
Normal file
45
src/lib/server/db/schema.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { relations } from 'drizzle-orm/relations';
|
||||||
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export const questions = sqliteTable('questions', {
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
creator: text('creator').notNull(),
|
||||||
|
content: text('content').notNull().unique(),
|
||||||
|
answerCount: integer('answer_count').notNull().default(0),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const answers = sqliteTable('answers', {
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
creator: text('creator').notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
questionId: text('question_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => questions.id),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date())
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
export const questionsIndexes = {
|
||||||
|
answerCountIdx: `CREATE INDEX idx_questions_answer_count ON questions (answer_count)`,
|
||||||
|
creatorIdx: `CREATE INDEX idx_questions_creator ON questions (creator)`
|
||||||
|
};
|
||||||
|
|
||||||
|
export const answersIndexes = {
|
||||||
|
questionIdIdx: `CREATE INDEX idx_answers_question_id ON answers (question_id)`,
|
||||||
|
creatorIdx: `CREATE INDEX idx_answers_creator ON answers (creator)`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
export const questionsRelations = relations(questions, ({ many }) => ({
|
||||||
|
answers: many(answers)
|
||||||
|
}));
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Picture } from 'vite-imagetools';
|
import type { Picture } from 'vite-imagetools';
|
||||||
|
import type { answers, questions } from './server/db/schema';
|
||||||
|
|
||||||
export type EnhancedImage = {
|
export type EnhancedImage = {
|
||||||
src: string | Picture;
|
src: string | Picture;
|
||||||
|
@ -75,3 +76,6 @@ export type Tag = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TagsObj = Record<string, Tag>;
|
export type TagsObj = Record<string, Tag>;
|
||||||
|
|
||||||
|
export type Answer = typeof answers.$inferSelect;
|
||||||
|
export type Question = typeof questions.$inferSelect & { answers: Answer[] };
|
||||||
|
|
62
src/routes/api/rahvatarkus/answer/+server.ts
Normal file
62
src/routes/api/rahvatarkus/answer/+server.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { questions, answers } from '$lib/server/db/schema';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const maxAnswers = 5;
|
||||||
|
|
||||||
|
export async function POST({ locals, request }) {
|
||||||
|
const { userId, questionId, content } = await request.json();
|
||||||
|
const { session } = locals;
|
||||||
|
|
||||||
|
if (!session?.data?.userId) return;
|
||||||
|
const user = session.data.userId;
|
||||||
|
|
||||||
|
if (!user || !userId || user !== userId) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
// Get question and validate in one query
|
||||||
|
const question = await tx
|
||||||
|
.select({
|
||||||
|
creator: questions.creator,
|
||||||
|
answerCount: questions.answerCount
|
||||||
|
})
|
||||||
|
.from(questions)
|
||||||
|
.where(eq(questions.id, questionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!question.length) {
|
||||||
|
return json({ error: 'Question not found' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [questionData] = question;
|
||||||
|
|
||||||
|
// if (questionData.creator === user) {
|
||||||
|
// return json({ error: 'Cannot answer own question' }, { status: 400 });
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (questionData.answerCount >= maxAnswers) {
|
||||||
|
return json({ error: 'No more answers needed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert answer and update count atomically
|
||||||
|
const [newAnswer] = await tx
|
||||||
|
.insert(answers)
|
||||||
|
.values({
|
||||||
|
content,
|
||||||
|
creator: userId,
|
||||||
|
questionId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(questions)
|
||||||
|
.set({ answerCount: sql`${questions.answerCount} + 1` })
|
||||||
|
.where(eq(questions.id, questionId));
|
||||||
|
|
||||||
|
return json(newAnswer);
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { questions, answers } from '$lib/server/db/schema';
|
||||||
|
import { desc, eq, gt, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET({ params }) {
|
||||||
|
const limit = Math.min(parseInt(params.limit) || 10, 10);
|
||||||
|
const offset = parseInt(params.offset) || 0;
|
||||||
|
|
||||||
|
// Get total in parallel with data
|
||||||
|
const totalPromise = db
|
||||||
|
.select({
|
||||||
|
count: sql`count(*)`
|
||||||
|
})
|
||||||
|
.from(questions)
|
||||||
|
.where(gt(questions.answerCount, 0));
|
||||||
|
|
||||||
|
const questionsPromise = db
|
||||||
|
.select({
|
||||||
|
id: questions.id,
|
||||||
|
content: questions.content,
|
||||||
|
creator: questions.creator,
|
||||||
|
createdAt: questions.createdAt,
|
||||||
|
answers: sql`json_group_array(json_object(
|
||||||
|
'id', ${answers.id},
|
||||||
|
'content', ${answers.content},
|
||||||
|
'creator', ${answers.creator},
|
||||||
|
'createdAt', ${answers.createdAt}
|
||||||
|
))`
|
||||||
|
})
|
||||||
|
.from(questions)
|
||||||
|
.orderBy(desc(questions.createdAt))
|
||||||
|
.innerJoin(answers, eq(questions.id, answers.questionId))
|
||||||
|
.where(gt(questions.answerCount, 0))
|
||||||
|
.groupBy(questions.id)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const [total, curr_questions] = await Promise.all([totalPromise, questionsPromise]);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
data: curr_questions.map((q) => ({
|
||||||
|
...q,
|
||||||
|
answers: JSON.parse(q.answers)
|
||||||
|
})),
|
||||||
|
meta: { limit, offset, total: total[0].count }
|
||||||
|
});
|
||||||
|
}
|
109
src/routes/api/rahvatarkus/question/+server.ts
Normal file
109
src/routes/api/rahvatarkus/question/+server.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export async function GET({ locals }) {
|
||||||
|
const { session } = locals;
|
||||||
|
if (!session?.data?.userId) return;
|
||||||
|
|
||||||
|
const user = session.data.userId;
|
||||||
|
|
||||||
|
// Use the answerCount field and avoid joins
|
||||||
|
const eligibleQuestions = await db
|
||||||
|
.select({
|
||||||
|
id: questions.id,
|
||||||
|
content: questions.content,
|
||||||
|
answerCount: questions.answerCount
|
||||||
|
})
|
||||||
|
.from(questions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
lt(questions.answerCount, 5),
|
||||||
|
not(
|
||||||
|
exists(
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(answers)
|
||||||
|
.where(and(eq(answers.questionId, questions.id), eq(answers.creator, user)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(sql`RANDOM()`)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!eligibleQuestions.length) {
|
||||||
|
return json({ error: 'No questions available' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(eligibleQuestions[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ locals, request }) {
|
||||||
|
const { userId, content }: { userId: string; content: string } = await request.json();
|
||||||
|
const { session } = locals;
|
||||||
|
|
||||||
|
if (!session?.data?.userId) return;
|
||||||
|
const user = session.data.userId;
|
||||||
|
|
||||||
|
if (!user || !userId || user !== userId) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content?.trim()) {
|
||||||
|
return json({ error: 'Content is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize content
|
||||||
|
const normalizedContent = content.trim();
|
||||||
|
const finalContent =
|
||||||
|
normalizedContent.at(-1) === '?' ? normalizedContent.slice(0, -1) : normalizedContent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use transaction to ensure data consistency
|
||||||
|
const [newQuestion] = await db.transaction(async (tx) => {
|
||||||
|
// Check for duplicate questions first
|
||||||
|
const existingQuestion = await tx
|
||||||
|
.select({ id: questions.id })
|
||||||
|
.from(questions)
|
||||||
|
.where(eq(questions.content, finalContent))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingQuestion.length > 0) {
|
||||||
|
throw new Error('Question already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user's recent questions (optional rate limiting)
|
||||||
|
const recentQuestions = await tx
|
||||||
|
.select({ count: sql`count(*)` })
|
||||||
|
.from(questions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(questions.creator, userId),
|
||||||
|
gt(questions.createdAt, sql`datetime('now', '-1 hour')`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recentQuestions[0].count >= 10) {
|
||||||
|
throw new Error('Too many questions in the last hour');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the new question
|
||||||
|
return await tx
|
||||||
|
.insert(questions)
|
||||||
|
.values({
|
||||||
|
content: finalContent,
|
||||||
|
creator: userId,
|
||||||
|
answerCount: 0,
|
||||||
|
createdAt: new Date()
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(newQuestion);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e instanceof Error ? e.message : 'Failed to create question';
|
||||||
|
return json({ error }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { baseURL, stuffSite } from '$lib/config.js';
|
import { baseURL, stuffSite } from '$lib/config.js';
|
||||||
|
|
||||||
|
import { Toaster } from '$lib/components/ui/sonner/index.js';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
const title = data?.name ? `${data.name} | ${stuffSite.name}` : stuffSite.name;
|
const title = data?.name ? `${data.name} | ${stuffSite.name}` : stuffSite.name;
|
||||||
|
@ -18,6 +20,8 @@
|
||||||
<meta property="og:image" content={baseURL + ogImage} />
|
<meta property="og:image" content={baseURL + ogImage} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
{#if data?.name === 'Vau kui vali'}
|
{#if data?.name === 'Vau kui vali'}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { setAlbumClientState } from '$lib/client/AlbumClientState.svelte';
|
import { setAlbumClientState } from '$lib/client/pakubiiti/AlbumClientState.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
|
43
src/routes/vinge/rahvatarkus/+layout.server.ts
Normal file
43
src/routes/vinge/rahvatarkus/+layout.server.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
import type { Question } from '$lib/types';
|
||||||
|
import { formSchema as questionSchema } from './question-schema';
|
||||||
|
import { formSchema as answerSchema } from './answer-schema';
|
||||||
|
|
||||||
|
import { superValidate } from 'sveltekit-superforms';
|
||||||
|
import { zod } from 'sveltekit-superforms/adapters';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ fetch, locals }) => {
|
||||||
|
const { session } = locals;
|
||||||
|
|
||||||
|
if (!session?.data?.userId) {
|
||||||
|
await session.setData({ userId: nanoid() });
|
||||||
|
await session.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = session.data.userId;
|
||||||
|
|
||||||
|
const res = await fetch('/api/rahvatarkus/question')
|
||||||
|
.then(async (res) => {
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: res.ok, data: data };
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
let question: Question | undefined = undefined;
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
question = res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: user,
|
||||||
|
question: question,
|
||||||
|
question_form: await superValidate(zod(questionSchema)),
|
||||||
|
answer_form: await superValidate({ questionId: question?.id }, zod(answerSchema), {
|
||||||
|
errors: false
|
||||||
|
})
|
||||||
|
};
|
||||||
|
};
|
13
src/routes/vinge/rahvatarkus/+layout.svelte
Normal file
13
src/routes/vinge/rahvatarkus/+layout.svelte
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import QuestionForm from './question-form.svelte';
|
||||||
|
import AnswerForm from './answer-form.svelte';
|
||||||
|
|
||||||
|
let { data, children } = $props();
|
||||||
|
|
||||||
|
$inspect(data);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<QuestionForm {data} />
|
||||||
|
<AnswerForm {data} />
|
||||||
|
|
||||||
|
{@render children()}
|
133
src/routes/vinge/rahvatarkus/+page.server.ts
Normal file
133
src/routes/vinge/rahvatarkus/+page.server.ts
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { formSchema as questionSchema } from './question-schema';
|
||||||
|
import { formSchema as answerSchema } from './answer-schema';
|
||||||
|
|
||||||
|
import { superValidate } from 'sveltekit-superforms';
|
||||||
|
import { zod } from 'sveltekit-superforms/adapters';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
const pageSize = 5;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
|
const page = Number(url.searchParams.get('leht')) || 0;
|
||||||
|
|
||||||
|
const archiveRes = fetch(`/api/rahvatarkus/archive/${pageSize}/${(page - 1) * pageSize}`)
|
||||||
|
.then((res) => {
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
streamed: {
|
||||||
|
archive: archiveRes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
answer: async (event) => {
|
||||||
|
const { session } = event.locals;
|
||||||
|
if (!session?.data?.userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = session.data.userId;
|
||||||
|
|
||||||
|
const form = await superValidate(event, zod(answerSchema));
|
||||||
|
|
||||||
|
if (!form.valid) {
|
||||||
|
return fail(400, {
|
||||||
|
form
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await event
|
||||||
|
.fetch('/api/rahvatarkus/answer', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId: user,
|
||||||
|
content: form.data.answer,
|
||||||
|
questionId: form.data.questionId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: res.ok, data: data };
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = response.data?.error;
|
||||||
|
|
||||||
|
if (form.errors.answer) {
|
||||||
|
form.errors.answer.push(errorMessage);
|
||||||
|
} else {
|
||||||
|
form.errors.answer = [errorMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fail(400, {
|
||||||
|
form
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
question: async (event) => {
|
||||||
|
const { session } = event.locals;
|
||||||
|
if (!session?.data?.userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = session.data.userId;
|
||||||
|
|
||||||
|
const form = await superValidate(event, zod(questionSchema));
|
||||||
|
|
||||||
|
if (!form.valid) {
|
||||||
|
return fail(400, {
|
||||||
|
form
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await event
|
||||||
|
.fetch('/api/rahvatarkus/question', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId: user, content: form.data.question })
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: res.ok, data: data };
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.data?.error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||||
|
const errorMessage = 'Sellel küsimusel on juba vastus.';
|
||||||
|
|
||||||
|
if (form.errors.question) {
|
||||||
|
form.errors.question.push(errorMessage);
|
||||||
|
} else {
|
||||||
|
form.errors.question = [errorMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fail(400, {
|
||||||
|
form
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
65
src/routes/vinge/rahvatarkus/+page.svelte
Normal file
65
src/routes/vinge/rahvatarkus/+page.svelte
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types.js';
|
||||||
|
|
||||||
|
import * as Accordion from '$lib/components/ui/accordion/index.js';
|
||||||
|
import * as Pagination from '$lib/components/ui/pagination/index.js';
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
$inspect(data);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#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}
|
||||||
|
<li>
|
||||||
|
{answer.content}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
{/each}
|
||||||
|
</Accordion.Root>
|
||||||
|
<Pagination.Root
|
||||||
|
onPageChange={(value: number) => {
|
||||||
|
goto(`?leht=${value}`);
|
||||||
|
}}
|
||||||
|
count={archive.meta.total}
|
||||||
|
perPage={data.pageSize}
|
||||||
|
page={data.page}
|
||||||
|
>
|
||||||
|
{#snippet children({ pages, currentPage })}
|
||||||
|
<Pagination.Content>
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.PrevButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
{#each pages as page (page.key)}
|
||||||
|
{#if page.type === 'ellipsis'}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Ellipsis />
|
||||||
|
</Pagination.Item>
|
||||||
|
{:else}
|
||||||
|
<Pagination.Item isVisible={currentPage === page.value}>
|
||||||
|
<Pagination.Link {page} isActive={currentPage === page.value}>
|
||||||
|
{page.value}
|
||||||
|
</Pagination.Link>
|
||||||
|
</Pagination.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.NextButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
</Pagination.Content>
|
||||||
|
{/snippet}
|
||||||
|
</Pagination.Root>
|
||||||
|
{/await}
|
51
src/routes/vinge/rahvatarkus/answer-form.svelte
Normal file
51
src/routes/vinge/rahvatarkus/answer-form.svelte
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formSchema, type FormSchema } from './answer-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 { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
|
||||||
|
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(`You submitted ${JSON.stringify(f.data, null, 2)}`);
|
||||||
|
} else {
|
||||||
|
toast.error('Please fix the errors in the form.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { form: formData, enhance } = form;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#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>{data.question.content}?</Form.Label>
|
||||||
|
<Input {...props} bind:value={$formData.answer} />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="questionId">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Input type="hidden" {...props} bind:value={$formData.questionId} />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Button>Vasta</Form.Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
8
src/routes/vinge/rahvatarkus/answer-schema.ts
Normal file
8
src/routes/vinge/rahvatarkus/answer-schema.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const formSchema = z.object({
|
||||||
|
questionId: z.string().length(21),
|
||||||
|
answer: z.string().min(2).max(250)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FormSchema = typeof formSchema;
|
41
src/routes/vinge/rahvatarkus/question-form.svelte
Normal file
41
src/routes/vinge/rahvatarkus/question-form.svelte
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formSchema, type FormSchema } from './question-schema';
|
||||||
|
|
||||||
|
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 { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
|
||||||
|
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(`You submitted ${JSON.stringify(f.data, null, 2)}`);
|
||||||
|
} else {
|
||||||
|
toast.error('Please fix the errors in the form.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { form: formData, enhance } = form;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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>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>
|
||||||
|
<Form.Button>Küsi</Form.Button>
|
||||||
|
</form>
|
7
src/routes/vinge/rahvatarkus/question-schema.ts
Normal file
7
src/routes/vinge/rahvatarkus/question-schema.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const formSchema = z.object({
|
||||||
|
question: z.string().min(2).max(50)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FormSchema = typeof formSchema;
|
Loading…
Add table
Reference in a new issue