feat: mvp
This commit is contained in:
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
43
src/lib/auth.ts
Normal file
43
src/lib/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Session, User } from '@auth/sveltekit';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from './server/db';
|
||||
import { users } from './server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
interface Event {
|
||||
locals: {
|
||||
auth(): Promise<Session | null>;
|
||||
};
|
||||
}
|
||||
interface UserWithId extends User {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function ensureAuth(event: Event): Promise<UserWithId> {
|
||||
const session = await getSession(event);
|
||||
if (!session) error(401, { message: 'Du muesch di zersch iiloge' });
|
||||
|
||||
const user = session?.user;
|
||||
if (!user || !user.email || !user.id) {
|
||||
error(401, { message: 'Du muesch di zersch iiloge' });
|
||||
}
|
||||
|
||||
return { ...user, id: user.id, email: user.email }; // weird thingamajig so that ts compiler is happy
|
||||
}
|
||||
export async function getSession(event: Event) {
|
||||
const session = await event.locals.auth();
|
||||
if (!session) return null;
|
||||
if (!session.user) error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' });
|
||||
|
||||
const res = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.limit(1)
|
||||
.where(eq(users.email, session.user.email ?? 'eaf9302d-9525-4f3e-8147-9620d2076f67')); //uuid as default to find nothing
|
||||
|
||||
if (!res[0]?.id) {
|
||||
error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' });
|
||||
}
|
||||
|
||||
return { expires: session.expires, user: { ...session.user, id: res[0].id } };
|
||||
}
|
||||
78
src/lib/components/RichText.svelte
Normal file
78
src/lib/components/RichText.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { FormatButtonGroup, TextEditor } from '@flowbite-svelte-plugins/texteditor';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { Helper } from 'flowbite-svelte';
|
||||
|
||||
let {
|
||||
value = $bindable('Hello World'),
|
||||
name,
|
||||
required = false,
|
||||
minlength
|
||||
}: { value?: string; name?: string; required?: boolean; minlength?: number } = $props();
|
||||
|
||||
let editorInstance = $state<Editor | null>(null);
|
||||
let updating: boolean = false;
|
||||
|
||||
$effect(() => {
|
||||
if (updating) {
|
||||
updating = false;
|
||||
return;
|
||||
}
|
||||
// Only update content if it is actually different to prevent cursor jumping
|
||||
if (editorInstance && editorInstance.getHTML() !== value) {
|
||||
editorInstance.commands.setContent(value);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!editorInstance) return;
|
||||
|
||||
editorInstance.on('update', (editor) => {
|
||||
updating = true;
|
||||
const content = editor.editor.getHTML();
|
||||
errorMessage = '';
|
||||
|
||||
if (editor.editor.isEmpty) {
|
||||
value = '';
|
||||
} else {
|
||||
value = content;
|
||||
}
|
||||
});
|
||||
});
|
||||
let errorMessage = $state('');
|
||||
// Derived state to easily toggle classes
|
||||
</script>
|
||||
|
||||
<div class="relative group">
|
||||
<TextEditor
|
||||
bind:editor={editorInstance}
|
||||
content="<p></p> <p></p>"
|
||||
contentprops={{ id: 'formats-ex' }}
|
||||
>
|
||||
<FormatButtonGroup editor={editorInstance} />
|
||||
</TextEditor>
|
||||
|
||||
{#if name}
|
||||
<input
|
||||
type="text"
|
||||
{name}
|
||||
bind:value
|
||||
{required}
|
||||
minlength={minlength ? minlength + 6 : undefined}
|
||||
tabindex="-1"
|
||||
style="opacity: 0; position: absolute; pointer-events: none; z-index: -1; bottom: 0; left: 50%; height: 0; width: 0;"
|
||||
oninvalid={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
// 1. Read the message (e.g., "Please fill out this field.")
|
||||
errorMessage = target.validationMessage;
|
||||
editorInstance?.commands.focus();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if errorMessage}
|
||||
<Helper class="mt-2" color="red">
|
||||
{errorMessage}
|
||||
</Helper>
|
||||
{/if}
|
||||
28
src/lib/components/UserDisplay.svelte
Normal file
28
src/lib/components/UserDisplay.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { User } from '@auth/sveltekit';
|
||||
import { Avatar, Dropdown } from 'flowbite-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { twMerge, type ClassNameValue } from 'tailwind-merge';
|
||||
let {
|
||||
user,
|
||||
children,
|
||||
class: className
|
||||
}: { user: User; children?: Snippet; class?: ClassNameValue } = $props();
|
||||
|
||||
let classList = $derived(twMerge('flex items-center gap-5', className));
|
||||
</script>
|
||||
|
||||
{#if children}
|
||||
<button class={classList}>
|
||||
<Avatar src={user.image ?? undefined} />
|
||||
<p>{user.name}</p>
|
||||
</button>
|
||||
<Dropdown simple>
|
||||
{@render children?.()}
|
||||
</Dropdown>
|
||||
{:else}
|
||||
<span class={classList}>
|
||||
<Avatar src={user.image ?? undefined} />
|
||||
<p>{user.name}</p>
|
||||
</span>
|
||||
{/if}
|
||||
23
src/lib/components/akti/AktiCard.svelte
Normal file
23
src/lib/components/akti/AktiCard.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Rating } from 'flowbite-svelte';
|
||||
interface Akti {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
rating?: number;
|
||||
}
|
||||
let { akti }: { akti: Akti } = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={resolve(`/akti/${akti.id}`)}
|
||||
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 hover:shadow-lg transition-shadow duration-200"
|
||||
>
|
||||
<h4>{akti.title}</h4>
|
||||
<p class="text-left">{akti.summary}</p>
|
||||
|
||||
{#if akti.rating}
|
||||
<Rating id="example-1b" total={5} size={30} rating={akti.rating} class="self-end mt-auto" />
|
||||
{/if}
|
||||
</a>
|
||||
26
src/lib/components/akti/AktiEditor.svelte
Normal file
26
src/lib/components/akti/AktiEditor.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Button, Input, Label, Textarea } from 'flowbite-svelte';
|
||||
import RichText from '../RichText.svelte';
|
||||
interface Akti {
|
||||
title: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
}
|
||||
let { akti }: { akti: Akti } = $props();
|
||||
</script>
|
||||
|
||||
<form method="POST" class="flex flex-col gap-5">
|
||||
<div>
|
||||
<Label>Titu</Label>
|
||||
<Input value={akti.title} type="text" name="title" required minlength={5} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Zämefassig</Label>
|
||||
<Textarea value={akti.summary} name="summary" required minlength={5} class="w-full min-h-40" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Inhaut</Label>
|
||||
<RichText value={akti.body} name="body" required />
|
||||
</div>
|
||||
<Button type="submit" class="grow-0 self-end">Spichere</Button>
|
||||
</form>
|
||||
62
src/lib/extractFormData.ts
Normal file
62
src/lib/extractFormData.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Code from: https://jovianmoon.io/posts/sveltekit-form-validation-with-valibot
|
||||
*/
|
||||
import { dev } from '$app/environment';
|
||||
import * as v from 'valibot';
|
||||
|
||||
export const extractFormData = async <TInput = unknown, TOutput = TInput>(
|
||||
request: Request,
|
||||
schema: v.BaseSchema<TInput, TOutput, v.BaseIssue<unknown>>
|
||||
): Promise<{
|
||||
data: TOutput | undefined;
|
||||
error: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
// Convert form data to an object with proper handling of multiple values
|
||||
formData.forEach((value, key) => {
|
||||
// Case 1: First time encountering this key
|
||||
if (result[key] === undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
// Case 2: Key exists and is already an array, add new value
|
||||
else if (Array.isArray(result[key])) {
|
||||
result[key].push(value);
|
||||
}
|
||||
// Case 3: Key exists but isn't an array yet, convert to array with both values
|
||||
else {
|
||||
result[key] = [result[key], value];
|
||||
}
|
||||
});
|
||||
|
||||
const validation = v.safeParse(schema, result);
|
||||
if (!validation.success) {
|
||||
if (dev) {
|
||||
console.error('Validation errors:');
|
||||
for (const error of validation.issues) {
|
||||
console.error(`- ${error.message}`);
|
||||
}
|
||||
}
|
||||
if (validation.issues && validation.issues.length > 0) {
|
||||
return {
|
||||
data: undefined,
|
||||
error: validation.issues.map((issue) => issue.message).join(', ')
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: undefined,
|
||||
error: 'Error validating form submission, please check everything carefully.'
|
||||
};
|
||||
}
|
||||
|
||||
return { data: validation.output as TOutput, error: null };
|
||||
} catch (error) {
|
||||
if (dev) {
|
||||
console.error(`Error extracting form data: ${error}`);
|
||||
}
|
||||
return { data: undefined, error: `Error extracting form data: ${error}` };
|
||||
}
|
||||
};
|
||||
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
10
src/lib/server/db/index.ts
Normal file
10
src/lib/server/db/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { building } from '$app/environment';
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = building ? postgres() : postgres(env.DATABASE_URL);
|
||||
|
||||
export const db = drizzle(client, { schema, casing: 'snake_case' });
|
||||
49
src/lib/server/db/schema/akti.ts
Normal file
49
src/lib/server/db/schema/akti.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import { integer, pgTable, real, text, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './user';
|
||||
|
||||
export const aktis = pgTable('akti', {
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
title: text().notNull(),
|
||||
summary: text().notNull(),
|
||||
body: text().notNull(),
|
||||
author: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
version: integer('version').notNull().default(1)
|
||||
});
|
||||
|
||||
export const ratings = pgTable('rating', {
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
aktiId: uuid()
|
||||
.notNull()
|
||||
.references(() => aktis.id, { onDelete: 'cascade' }),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
rating: real().notNull(),
|
||||
comment: text(),
|
||||
aktiVersion: integer('akti_version').notNull()
|
||||
});
|
||||
|
||||
export const aktisRelations = relations(aktis, ({ one }) => ({
|
||||
author: one(users, {
|
||||
fields: [aktis.author],
|
||||
references: [users.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const ratingsRelations = relations(ratings, ({ one }) => ({
|
||||
akti: one(aktis, {
|
||||
fields: [ratings.aktiId],
|
||||
references: [aktis.id]
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [ratings.userId],
|
||||
references: [users.id]
|
||||
})
|
||||
}));
|
||||
76
src/lib/server/db/schema/auth.ts
Normal file
76
src/lib/server/db/schema/auth.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { boolean, integer, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import type { AdapterAccountType } from '@auth/core/adapters';
|
||||
import { users } from './user';
|
||||
|
||||
export const accounts = pgTable(
|
||||
'account',
|
||||
{
|
||||
userId: text('userId')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
type: text('type').$type<AdapterAccountType>().notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
providerAccountId: text('providerAccountId').notNull(),
|
||||
refresh_token: text('refresh_token'),
|
||||
access_token: text('access_token'),
|
||||
expires_at: integer('expires_at'),
|
||||
token_type: text('token_type'),
|
||||
scope: text('scope'),
|
||||
id_token: text('id_token'),
|
||||
session_state: text('session_state')
|
||||
},
|
||||
(account) => [
|
||||
{
|
||||
compoundKey: primaryKey({
|
||||
columns: [account.provider, account.providerAccountId]
|
||||
})
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
export const sessions = pgTable('session', {
|
||||
sessionToken: text('sessionToken').primaryKey(),
|
||||
userId: text('userId')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expires: timestamp('expires', { mode: 'date' }).notNull()
|
||||
});
|
||||
|
||||
export const verificationTokens = pgTable(
|
||||
'verificationToken',
|
||||
{
|
||||
identifier: text('identifier').notNull(),
|
||||
token: text('token').notNull(),
|
||||
expires: timestamp('expires', { mode: 'date' }).notNull()
|
||||
},
|
||||
(verificationToken) => [
|
||||
{
|
||||
compositePk: primaryKey({
|
||||
columns: [verificationToken.identifier, verificationToken.token]
|
||||
})
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
export const authenticators = pgTable(
|
||||
'authenticator',
|
||||
{
|
||||
credentialID: text('credentialID').notNull().unique(),
|
||||
userId: text('userId')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
providerAccountId: text('providerAccountId').notNull(),
|
||||
credentialPublicKey: text('credentialPublicKey').notNull(),
|
||||
counter: integer('counter').notNull(),
|
||||
credentialDeviceType: text('credentialDeviceType').notNull(),
|
||||
credentialBackedUp: boolean('credentialBackedUp').notNull(),
|
||||
transports: text('transports')
|
||||
},
|
||||
(authenticator) => [
|
||||
{
|
||||
compositePK: primaryKey({
|
||||
columns: [authenticator.userId, authenticator.credentialID]
|
||||
})
|
||||
}
|
||||
]
|
||||
);
|
||||
3
src/lib/server/db/schema/index.ts
Normal file
3
src/lib/server/db/schema/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './auth.ts';
|
||||
export * from './user.ts';
|
||||
export * from './akti.ts';
|
||||
11
src/lib/server/db/schema/user.ts
Normal file
11
src/lib/server/db/schema/user.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const users = pgTable('user', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: text('name'),
|
||||
email: text('email').unique(),
|
||||
emailVerified: timestamp('emailVerified', { mode: 'date' }),
|
||||
image: text('image')
|
||||
});
|
||||
Reference in New Issue
Block a user