1 Commits

Author SHA1 Message Date
schreifuchs 6d322f2056 chore: add pipeline
Commit / ci (push) Successful in 35s
Release / publish (push) Failing after 2m21s
2025-11-26 22:16:19 +01:00
28 changed files with 52 additions and 1659 deletions
+1 -6
View File
@@ -3,9 +3,7 @@ name: Commit
on: on:
push: push:
# only trigger on branches, not on tags # only trigger on branches, not on tags
branches: branches: '**'
- 'main'
- 'dev'
jobs: jobs:
# Job 1: Lint and Test (Type Check) # Job 1: Lint and Test (Type Check)
@@ -36,6 +34,3 @@ jobs:
- name: Type Check (Svelte Check) - name: Type Check (Svelte Check)
# Based on your package.json "check" script # Based on your package.json "check" script
run: pnpm check run: pnpm check
- name: Run Tests (Vitest)
run: pnpm run test
+1 -3
View File
@@ -17,10 +17,8 @@ jobs:
http = true http = true
insecure = true insecure = true
- name: login gitea registry - name: login
run: docker login -u schreifuchs -p ${{ secrets.REGISTRY_TOKEN }} git.schreifuchs.ch run: docker login -u schreifuchs -p ${{ secrets.REGISTRY_TOKEN }} git.schreifuchs.ch
- name: login dockerhub
run: docker login -u aktitiel -p ${{ secrets.DOCKER_HUB_TOKEN}}
- name: Build and push Docker image - name: Build and push Docker image
uses: https://github.com/docker/build-push-action@v5 uses: https://github.com/docker/build-push-action@v5
with: with:
+2 -4
View File
@@ -18,14 +18,12 @@ jobs:
http = true http = true
insecure = true insecure = true
- name: login gitea registry - name: login
run: docker login -u schreifuchs -p ${{ secrets.REGISTRY_TOKEN }} git.schreifuchs.ch run: docker login -u schreifuchs -p ${{ secrets.REGISTRY_TOKEN }} git.schreifuchs.ch
- name: login dockerhub
run: docker login -u aktitiel -p ${{ secrets.DOCKER_HUB_TOKEN}}
- name: Build and push Docker image - name: Build and push Docker image
uses: https://github.com/docker/build-push-action@v5 uses: https://github.com/docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
tags: 'git.schreifuchs.ch/schreifuchs/aktiteil:${{ github.ref_name }},git.schreifuchs.ch/schreifuchs/aktiteil:latest' tags: 'git.schreifuchs.ch/schreifuchs/aktiteil:${{ github.ref_name }},git.schreifuchs.ch/aktiteil/tdontd:latest'
-1
View File
@@ -1,3 +1,2 @@
pnpm run format pnpm run format
pnpm run lint pnpm run lint
pnpm run test
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "rating" ALTER COLUMN "comment" SET NOT NULL;
-436
View File
@@ -1,436 +0,0 @@
{
"id": "0528e286-2cd4-4447-a3a4-5a7c2cda83b9",
"prevId": "8f81af0f-3a4e-4fb8-b7f1-cf4e6eec7ecb",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.authenticator": {
"name": "authenticator",
"schema": "",
"columns": {
"credentialID": {
"name": "credentialID",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credentialPublicKey": {
"name": "credentialPublicKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"credentialDeviceType": {
"name": "credentialDeviceType",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credentialBackedUp": {
"name": "credentialBackedUp",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"authenticator_userId_user_id_fk": {
"name": "authenticator_userId_user_id_fk",
"tableFrom": "authenticator",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"authenticator_credentialID_unique": {
"name": "authenticator_credentialID_unique",
"nullsNotDistinct": false,
"columns": ["credentialID"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verificationToken": {
"name": "verificationToken",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.akti": {
"name": "akti",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"akti_user_id_user_id_fk": {
"name": "akti_user_id_user_id_fk",
"tableFrom": "akti",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.rating": {
"name": "rating",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"akti_id": {
"name": "akti_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"rating": {
"name": "rating",
"type": "real",
"primaryKey": false,
"notNull": true
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": true
},
"akti_version": {
"name": "akti_version",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"rating_akti_id_akti_id_fk": {
"name": "rating_akti_id_akti_id_fk",
"tableFrom": "rating",
"tableTo": "akti",
"columnsFrom": ["akti_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"rating_user_id_user_id_fk": {
"name": "rating_user_id_user_id_fk",
"tableFrom": "rating",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-7
View File
@@ -29,13 +29,6 @@
"when": 1764000151184, "when": 1764000151184,
"tag": "0003_tranquil_iron_monger", "tag": "0003_tranquil_iron_monger",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1765385786051,
"tag": "0004_wealthy_groot",
"breakpoints": true
} }
] ]
} }
+1 -8
View File
@@ -10,8 +10,6 @@
"prepare": "husky", "prepare": "husky",
"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",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"db:start": "docker compose up", "db:start": "docker compose up",
@@ -30,11 +28,8 @@
"@sveltejs/kit": "^2.49.0", "@sveltejs/kit": "^2.49.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@tiptap/core": "3.7.2", "@tiptap/core": "3.7.2",
"@types/node": "^20.19.25", "@types/node": "^20.19.25",
"@types/sanitize-html": "^2.16.1",
"drizzle-kit": "^0.31.7", "drizzle-kit": "^0.31.7",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@@ -44,7 +39,6 @@
"flowbite-svelte-icons": "^3.0.0", "flowbite-svelte-icons": "^3.0.0",
"globals": "^16.5.0", "globals": "^16.5.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^29.0.1",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
@@ -53,8 +47,7 @@
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.47.0",
"vite": "^7.2.4", "vite": "^7.2.4"
"vitest": "^4.1.2"
}, },
"dependencies": { "dependencies": {
"@auth/drizzle-adapter": "^1.11.1", "@auth/drizzle-adapter": "^1.11.1",
-787
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,5 +1,3 @@
import { DefaultSession } from '@auth/sveltekit';
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {
@@ -12,13 +10,4 @@ declare global {
} }
} }
declare module '@auth/sveltekit' {
interface Session {
user: {
id: string;
email: string;
} & DefaultSession['user'];
}
}
export {}; export {};
+11 -8
View File
@@ -1,36 +1,39 @@
import type { Session } from '@auth/sveltekit'; import type { Session, User } from '@auth/sveltekit';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { db } from './db'; import { db } from './server/db';
import { users } from './db/schema'; import { users } from './server/db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
interface Event { interface Event {
locals: { locals: {
auth(): Promise<Session | null>; auth(): Promise<Session | null>;
}; };
} }
interface UserWithId extends User {
id: string;
email: string;
}
export async function ensureAuth(event: Event): Promise<Session['user']> { export async function ensureAuth(event: Event): Promise<UserWithId> {
const session = await getSession(event); const session = await getSession(event);
if (!session) error(401, { message: 'Du muesch di zersch iiloge' }); if (!session) error(401, { message: 'Du muesch di zersch iiloge' });
const user = session.user; const user = session?.user;
if (!user || !user.email || !user.id) { if (!user || !user.email || !user.id) {
error(401, { message: 'Du muesch di zersch iiloge' }); error(401, { message: 'Du muesch di zersch iiloge' });
} }
return user; return { ...user, id: user.id, email: user.email }; // weird thingamajig so that ts compiler is happy
} }
export async function getSession(event: Event) { export async function getSession(event: Event) {
const session = await event.locals.auth(); const session = await event.locals.auth();
if (!session) return null; if (!session) return null;
if (!session.user) error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' }); if (!session.user) error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' });
if (!session?.user?.email) return null;
const res = await db const res = await db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .from(users)
.limit(1) .limit(1)
.where(eq(users.email, session.user.email)); .where(eq(users.email, session.user.email ?? 'eaf9302d-9525-4f3e-8147-9620d2076f67')); //uuid as default to find nothing
if (!res[0]?.id) { if (!res[0]?.id) {
error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' }); error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' });
@@ -1,31 +0,0 @@
<script lang="ts">
import { Rating as FbRating } from 'flowbite-svelte';
import UserDisplay from '../UserDisplay.svelte';
interface Rating {
rating: number;
comment: string;
outdated?: boolean;
user?: {
name: string | null;
image: string | null;
};
}
let { rating }: { rating: Rating } = $props();
</script>
<div
class="w-full bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 shadow-md flex flex-col items-start p-5 gap-5"
>
{#if rating.user}
<UserDisplay user={rating.user} />
{/if}
{#if rating.outdated}
<span class="self-end">i</span>
{/if}
<p class="text-left">{rating.comment}</p>
{#if rating.rating}
<FbRating id="example-1b" total={5} size={30} rating={rating.rating} class="self-end mt-auto" />
{/if}
</div>
@@ -1,27 +0,0 @@
<script lang="ts">
import { Button, Label, Textarea } from 'flowbite-svelte';
import RatingInput from './RatingInput.svelte';
interface Rating {
rating: number;
comment: string;
}
let { rating }: { rating: Rating } = $props();
</script>
<form method="POST" class="flex flex-col gap-5">
<div>
<Label>Komentar</Label>
<Textarea
value={rating.comment}
name="comment"
required
minlength={5}
class="w-full min-h-40"
/>
</div>
<div>
<Label>Bewärtig</Label>
<RatingInput value={rating.rating} name="rating" />
</div>
<Button type="submit" class="grow-0 self-end">Spichere</Button>
</form>
@@ -1,63 +0,0 @@
<script lang="ts">
import { StarHalfSolid, StarSolid } from 'flowbite-svelte-icons';
import { twMerge, type ClassNameValue } from 'tailwind-merge';
let {
value = $bindable(5),
name,
disabled = false,
class: className
}: { value?: number; name?: string; disabled?: boolean; class?: ClassNameValue } = $props();
let stars = $derived(
[0, 1, 2, 3, 4].map((i) => {
if (value - i >= 1) {
return { id: i, value: 1 };
} else if (value - i >= 0.5) {
return { id: i, value: 0.5 };
}
return { id: i, value: 0 };
})
);
</script>
{#if name}
<input
type="number"
{name}
bind:value
tabindex="-1"
style="opacity: 0; position: absolute; pointer-events: none; z-index: -1; bottom: 0; left: 50%; height: 0; width: 0;"
/>
{/if}
<span class={twMerge('flex justify-between ', className)}>
<!-- es -->
{#each stars as s (s.id)}
<button
type="button"
{disabled}
onclick={() => {
if (s.id === 0 && value == 0.5) {
value = 0;
return;
}
if (value === s.id + 1) {
value = s.id + 0.5;
return;
}
value = s.id + 1;
}}
class="grid grid-cols-2 grid-rows-1"
>
{#if s.value >= 1}
<StarSolid class="col-span-2 text-amber-400" />
{:else}
<StarSolid class="col-span-2 col-start-1 row-start-1" />
{#if s.value >= 0.5}
<StarHalfSolid class="col-span-1 col-start-1 row-start-1 text-amber-400" />
{/if}
{/if}
</button>
{/each}
</span>
-46
View File
@@ -1,46 +0,0 @@
import { describe, it, expect } from 'vitest';
import { extractFormData } from './extractFormData';
import * as v from 'valibot';
describe('extractFormData', () => {
it('should successfully extract and validate correct form data', async () => {
const formData = new FormData();
formData.append('name', 'John Doe');
formData.append('age', '30');
const request = new Request('http://localhost', {
method: 'POST',
body: formData
});
const schema = v.object({
name: v.string(),
age: v.string()
});
const result = await extractFormData(request, schema);
expect(result.error).toBeNull();
expect(result.data).toEqual({ name: 'John Doe', age: '30' });
});
it('should fail validation with missing required fields', async () => {
const formData = new FormData();
formData.append('age', '30');
const request = new Request('http://localhost', {
method: 'POST',
body: formData
});
const schema = v.object({
name: v.string(),
age: v.string()
});
const result = await extractFormData(request, schema);
expect(result.data).toBeUndefined();
expect(result.error).toBeTypeOf('string');
});
});
-18
View File
@@ -1,18 +0,0 @@
import { db } from '$lib/server/db';
import { aktis, ratings } from '$lib/server/db/schema';
import { avg, eq } from 'drizzle-orm';
export async function getAktisWithAvgRating(limit = 20, offset = 0) {
return await db
.select({
id: aktis.id,
title: aktis.title,
summary: aktis.summary,
rating: avg(ratings.rating)
})
.from(aktis)
.leftJoin(ratings, eq(aktis.id, ratings.aktiId))
.groupBy(aktis.id, aktis.title, aktis.summary)
.limit(limit)
.offset(offset);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export const ratings = pgTable('rating', {
.notNull() .notNull()
.references(() => users.id, { onDelete: 'cascade' }), .references(() => users.id, { onDelete: 'cascade' }),
rating: real().notNull(), rating: real().notNull(),
comment: text().notNull(), comment: text(),
aktiVersion: integer('akti_version').notNull() aktiVersion: integer('akti_version').notNull()
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
import { getSession } from '$lib/server/session'; import { getSession as getSession } from '$lib/auth';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => { export const load: LayoutServerLoad = async (event) => {
+14 -6
View File
@@ -1,11 +1,19 @@
import { getAktisWithAvgRating } from '$lib/server/db/queries'; import { db } from '$lib/server/db';
import { aktis, ratings } from '$lib/server/db/schema';
import { avg, eq } from 'drizzle-orm';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => { export const load: PageServerLoad = async () => {
const offset = Number(url.searchParams.get('offset')) || 0; const a = await db
const limit = 20; .select({
id: aktis.id,
const a = await getAktisWithAvgRating(limit, offset); title: aktis.title,
summary: aktis.summary,
rating: avg(ratings.rating)
})
.from(aktis)
.leftJoin(ratings, eq(aktis.id, ratings.aktiId))
.groupBy(aktis.id, aktis.title, aktis.summary);
return { return {
aktis: a.map((a) => ({ ...a, rating: a.rating ? parseFloat(a.rating) : undefined })) aktis: a.map((a) => ({ ...a, rating: a.rating ? parseFloat(a.rating) : undefined }))
+1 -44
View File
@@ -1,55 +1,12 @@
<script lang="ts"> <script lang="ts">
import AktiCard from '$lib/components/akti/AktiCard.svelte'; import AktiCard from '$lib/components/akti/AktiCard.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import { Spinner } from 'flowbite-svelte';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let aktis = $state(data.aktis);
let offset = $state(data.aktis.length);
let loading = $state(false);
let hasMore = $state(data.aktis.length >= 20);
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
const res = await fetch(`/api/aktis?offset=${offset}`);
const newAktis = await res.json();
if (newAktis.length < 20) {
hasMore = false;
}
aktis = [...aktis, ...newAktis];
offset += newAktis.length;
loading = false;
}
function infiniteScroll(node: HTMLElement) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
});
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}
</script> </script>
<div class="grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 3xl:grid-cols-5"> <div class="grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 3xl:grid-cols-5">
{#each aktis as akti (akti.id)} {#each data.aktis as akti (akti.id)}
<AktiCard {akti}></AktiCard> <AktiCard {akti}></AktiCard>
{/each} {/each}
</div> </div>
<div class="mt-10 flex justify-center h-20">
{#if loading}
<Spinner />
{:else if hasMore}
<div use:infiniteScroll></div>
{/if}
</div>
+1 -4
View File
@@ -4,10 +4,9 @@ import { extractFormData } from '$lib/extractFormData';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import * as v from 'valibot'; import * as v from 'valibot';
import { ensureAuth } from '$lib/server/session'; import { ensureAuth } from '$lib/auth';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { aktis } from '$lib/server/db/schema'; import { aktis } from '$lib/server/db/schema';
import sanitizeHtml from 'sanitize-html';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
await ensureAuth(event); await ensureAuth(event);
return {}; return {};
@@ -29,8 +28,6 @@ export const actions = {
if (!akti) return {}; if (!akti) return {};
akti.body = sanitizeHtml(akti.body);
const res = await db const res = await db
.insert(aktis) .insert(aktis)
.values({ ...akti, author: user.id! }) .values({ ...akti, author: user.id! })
+14 -18
View File
@@ -1,25 +1,23 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { aktis, ratings } from '$lib/server/db/schema'; import { aktis, ratings } from '$lib/server/db/schema';
import { error, fail, redirect, type Actions } from '@sveltejs/kit'; import { error, redirect, type Actions } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { ensureAuth } from '$lib/server/session'; import { ensureAuth } from '$lib/auth';
import { extractFormData } from '$lib/extractFormData'; import { extractFormData } from '$lib/extractFormData';
import * as v from 'valibot'; import * as v from 'valibot';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import sanitizeHtml from 'sanitize-html';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const [akti, r] = await Promise.all([ const akti = await db.query.aktis.findFirst({
db.query.aktis.findFirst({
where: eq(aktis.id, event.params.aktiId), where: eq(aktis.id, event.params.aktiId),
with: { author: true } with: { author: true }
}), });
db.query.ratings.findMany({
const r = await db.query.ratings.findMany({
with: { user: true }, with: { user: true },
where: eq(ratings.aktiId, event.params.aktiId) where: eq(ratings.aktiId, event.params.aktiId)
}) });
]);
if (!akti) { if (!akti) {
error(404, { message: 'Die Akti gits garnid, sorry...' }); error(404, { message: 'Die Akti gits garnid, sorry...' });
@@ -56,15 +54,13 @@ export const actions = {
) )
).data; ).data;
if (!changeRequest) return fail(400, { message: 'Invalid data' }); if (!changeRequest) return error(400);
changeRequest.body = sanitizeHtml(changeRequest.body); const res = await db
.insert(aktis)
.values({ ...changeRequest, author: user.id, version: akti[0].version + 1 })
.returning({ id: aktis.id });
await db return redirect(303, resolve(`/akti/[aktiId]`, { aktiId: res[0].id }));
.update(aktis)
.set({ ...changeRequest, version: akti[0].version + 1 })
.where(and(eq(aktis.author, user.id), eq(aktis.id, event.params.aktiId)));
return redirect(303, resolve(`/akti/[aktiId]`, { aktiId: event.params.aktiId }));
} }
} satisfies Actions; } satisfies Actions;
-22
View File
@@ -4,17 +4,10 @@
import { EditOutline, CloseOutline } from 'flowbite-svelte-icons'; import { EditOutline, CloseOutline } from 'flowbite-svelte-icons';
import AktiEditor from '$lib/components/akti/AktiEditor.svelte'; import AktiEditor from '$lib/components/akti/AktiEditor.svelte';
import UserDisplay from '$lib/components/UserDisplay.svelte'; import UserDisplay from '$lib/components/UserDisplay.svelte';
import RatingCard from '$lib/components/rating/RatingCard.svelte';
import { resolve } from '$app/paths';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let edit = $state(false); let edit = $state(false);
let canComment = $derived.by(() => {
if (!data.session) return false;
if (data.akti.author.id === data.session.user.id) return false;
return true;
});
</script> </script>
<div class="flex justify-between"> <div class="flex justify-between">
@@ -44,19 +37,4 @@
</div> </div>
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html data.akti.body} {@html data.akti.body}
<div class="mt-10 mb-5 flex justify-between items-center">
<h3>Kommentär</h3>
{#if canComment}
<Button href={resolve('/akti/[aktiId]/comment', { aktiId: data.akti.id })}>
Ä Kommentar da la
</Button>
{/if}
</div>
<section class="grid grid-cols-1 gap-5 2xl:grid-cols-3">
{#each data.ratings as rating (rating.id)}
<RatingCard {rating} />
{/each}
</section>
{/if} {/if}
@@ -1,68 +0,0 @@
import type { PageServerLoad } from './$types';
import { ensureAuth } from '$lib/server/session';
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { extractFormData } from '$lib/extractFormData';
import { aktis, ratings } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import * as v from 'valibot';
import { db } from '$lib/server/db';
import { resolve } from '$app/paths';
export const load: PageServerLoad = async (event) => {
const user = await ensureAuth(event);
const res = await db
.select({ authorId: aktis.author })
.from(aktis)
.where(eq(aktis.id, event.params.aktiId));
if (!res[0]) return error(404);
if (res[0].authorId === user.id) return error(403);
return;
};
export const actions = {
default: async (event) => {
const user = await ensureAuth(event);
if (!event.params.aktiId) return error(404);
const akti = await db
.select({ id: aktis.id, version: aktis.version, author: aktis.author })
.from(aktis)
.limit(1)
.where(eq(aktis.id, event.params.aktiId));
if (!akti || akti.length == 0) return error(404);
if (akti[0].author == user.id) return error(403);
const rating = (
await extractFormData(
event.request,
v.object({
comment: v.pipe(v.string(), v.minLength(5)),
rating: v.pipe(
v.string(),
v.transform((i) => Number.parseFloat(i)),
v.minValue(0),
v.maxValue(5)
)
})
)
).data;
if (!rating) return fail(400, { message: 'Invalid data' });
await db.insert(ratings).values({
...rating,
userId: user.id,
aktiId: event.params.aktiId,
aktiVersion: akti[0].version
});
return redirect(303, resolve(`/akti/[aktiId]`, { aktiId: event.params.aktiId }));
}
} satisfies Actions;
@@ -1,10 +0,0 @@
<script lang="ts">
import RatingEditor from '$lib/components/rating/RatingEditor.svelte';
</script>
<RatingEditor
rating={{
comment: '',
rating: 5
}}
/>
-11
View File
@@ -1,11 +0,0 @@
import { getAktisWithAvgRating } from '$lib/server/db/queries';
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ url }) => {
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = 20;
const a = await getAktisWithAvgRating(limit, offset);
return json(a.map((a) => ({ ...a, rating: a.rating ? parseFloat(a.rating) : undefined })));
};
-1
View File
@@ -1 +0,0 @@
import '@testing-library/jest-dom/vitest';
-12
View File
@@ -1,12 +0,0 @@
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
export default defineConfig({
plugins: [sveltekit(), svelteTesting()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
setupFiles: ['./vitest-setup.ts']
}
});