feat: mvp

This commit is contained in:
2025-11-26 21:48:06 +01:00
commit 5ab04b9acb
58 changed files with 14997 additions and 0 deletions

74
src/app.css Normal file
View File

@@ -0,0 +1,74 @@
@import 'tailwindcss';
@plugin 'flowbite/plugin';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary-50: #fff5f2;
--color-primary-100: #fff1ee;
--color-primary-200: #ffe4de;
--color-primary-300: #ffd5cc;
--color-primary-400: #ffbcad;
--color-primary-500: #fe795d;
--color-primary-600: #ef562f;
--color-primary-700: #eb4f27;
--color-primary-800: #cc4522;
--color-primary-900: #a5371b;
--color-secondary-50: #f0f9ff;
--color-secondary-100: #e0f2fe;
--color-secondary-200: #bae6fd;
--color-secondary-300: #7dd3fc;
--color-secondary-400: #38bdf8;
--color-secondary-500: #0ea5e9;
--color-secondary-600: #0284c7;
--color-secondary-700: #0369a1;
--color-secondary-800: #075985;
--color-secondary-900: #0c4a6e;
}
@source "../node_modules/flowbite-svelte/dist";
@source "../node_modules/flowbite-svelte-icons/dist";
@layer base {
/* disable chrome cancel button */
input[type='search']::-webkit-search-cancel-button {
display: none;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 700;
line-height: 1.2;
}
h1 {
@apply text-4xl md:text-5xl lg:text-6xl;
letter-spacing: -0.02em;
}
h2 {
@apply text-3xl md:text-4xl lg:text-5xl;
letter-spacing: -0.02em;
}
h3 {
@apply text-2xl md:text-3xl lg:text-4xl;
}
h4 {
@apply text-xl md:text-2xl lg:text-3xl;
}
h5 {
@apply text-lg md:text-xl lg:text-2xl;
}
h6 {
@apply text-base md:text-lg lg:text-xl;
}
}

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

14
src/app.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body
data-sveltekit-preload-data="hover"
class="dark:bg-gray-800 dark:text-gray-200 min-h-screen flex flex-col"
>
%sveltekit.body%
</body>
</html>

17
src/auth.ts Normal file
View File

@@ -0,0 +1,17 @@
import { SvelteKitAuth } from '@auth/sveltekit';
import Nextcloud from '@auth/sveltekit/providers/nextcloud';
import { env } from '$env/dynamic/private';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '$lib/server/db';
export const { handle, signIn, signOut } = SvelteKitAuth({
trustHost: env.AUTH_TRUST_HOST === 'true',
adapter: DrizzleAdapter(db),
providers: [
Nextcloud({
clientId: env.AUTH_NEXTCLOUD_ID,
clientSecret: env.AUTH_NEXTCLOUD_SECRET,
issuer: env.AUTH_NEXTCLOUD_ISSUER
})
]
});

1
src/hooks.server.ts Normal file
View File

@@ -0,0 +1 @@
export { handle } from './auth';

View 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
View 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 } };
}

View 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}

View 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}

View 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>

View 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>

View 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
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View 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' });

View 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]
})
}));

View 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]
})
}
]
);

View File

@@ -0,0 +1,3 @@
export * from './auth.ts';
export * from './user.ts';
export * from './akti.ts';

View 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')
});

7
src/routes/+error.svelte Normal file
View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<div class="flex items-center justify-center grow">
<h3>{page.status}: {page.error?.message ?? 'Upsi, da isch öppis kaputt gange...'}</h3>
</div>

View File

@@ -0,0 +1,10 @@
import { getSession as getSession } from '$lib/auth';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
const session = await getSession(event);
return {
session
};
};

47
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,47 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import {
Navbar,
NavBrand,
NavLi,
NavUl,
NavHamburger,
DropdownItem,
Button
} from 'flowbite-svelte';
import type { LayoutProps } from './$types';
import { signIn, signOut } from '@auth/sveltekit/client';
import UserDisplay from '$lib/components/UserDisplay.svelte';
let { data, children }: LayoutProps = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<Navbar class="border-b border-gray-200 dark:border-gray-700">
<NavBrand href="/">
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">aktiteil</span
>
</NavBrand>
<NavHamburger />
<NavUl>
<NavLi href="/">Dehei</NavLi>
<NavLi href="/akti">Neui Akti</NavLi>
</NavUl>
{#if data.session?.user}
<UserDisplay user={data.session.user}>
<DropdownItem>
<Button onclick={() => signOut()}>Sign out</Button>
</DropdownItem>
</UserDisplay>
{:else}
<Button onclick={() => signIn('nextcloud')}>Signin</Button>
{/if}
</Navbar>
<main class="p-5 sm:px-20 2xl:px-60 flex flex-col h-full grow">
{@render children()}
</main>

View File

@@ -0,0 +1,21 @@
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';
export const load: PageServerLoad = async () => {
const a = 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);
return {
aktis: a.map((a) => ({ ...a, rating: a.rating ? parseFloat(a.rating) : undefined }))
};
};

12
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,12 @@
<script lang="ts">
import AktiCard from '$lib/components/akti/AktiCard.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div class="grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 3xl:grid-cols-5">
{#each data.aktis as akti (akti.id)}
<AktiCard {akti}></AktiCard>
{/each}
</div>

View File

@@ -0,0 +1,38 @@
import { redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { extractFormData } from '$lib/extractFormData';
import { resolve } from '$app/paths';
import * as v from 'valibot';
import { ensureAuth } from '$lib/auth';
import { db } from '$lib/server/db';
import { aktis } from '$lib/server/db/schema';
export const load: PageServerLoad = async (event) => {
await ensureAuth(event);
return {};
};
export const actions = {
default: async (event) => {
const user = await ensureAuth(event);
const akti = (
await extractFormData(
event.request,
v.object({
title: v.pipe(v.string(), v.minLength(5)),
summary: v.pipe(v.string(), v.minLength(5)),
body: v.pipe(v.string(), v.minLength(5))
})
)
).data;
if (!akti) return {};
const res = await db
.insert(aktis)
.values({ ...akti, author: user.id! })
.returning({ id: aktis.id });
return redirect(303, resolve(`/akti/[aktiId]`, { aktiId: res[0].id }));
}
} satisfies Actions;

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import AktiEditor from '$lib/components/akti/AktiEditor.svelte';
</script>
<AktiEditor akti={{ title: '', summary: '', body: '' }} />

View File

@@ -0,0 +1,66 @@
import { db } from '$lib/server/db';
import { aktis, ratings } from '$lib/server/db/schema';
import { error, redirect, type Actions } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { ensureAuth } from '$lib/auth';
import { extractFormData } from '$lib/extractFormData';
import * as v from 'valibot';
import { resolve } from '$app/paths';
export const load: PageServerLoad = async (event) => {
const akti = await db.query.aktis.findFirst({
where: eq(aktis.id, event.params.aktiId),
with: { author: true }
});
const r = await db.query.ratings.findMany({
with: { user: true },
where: eq(ratings.aktiId, event.params.aktiId)
});
if (!akti) {
error(404, { message: 'Die Akti gits garnid, sorry...' });
}
return {
akti,
ratings: r
};
};
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 changeRequest = (
await extractFormData(
event.request,
v.object({
title: v.pipe(v.string(), v.minLength(5)),
summary: v.pipe(v.string(), v.minLength(5)),
body: v.pipe(v.string(), v.minLength(5))
})
)
).data;
if (!changeRequest) return error(400);
const res = await db
.insert(aktis)
.values({ ...changeRequest, author: user.id, version: akti[0].version + 1 })
.returning({ id: aktis.id });
return redirect(303, resolve(`/akti/[aktiId]`, { aktiId: res[0].id }));
}
} satisfies Actions;

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { Button } from 'flowbite-svelte';
import type { PageProps } from './$types';
import { EditOutline, CloseOutline } from 'flowbite-svelte-icons';
import AktiEditor from '$lib/components/akti/AktiEditor.svelte';
import UserDisplay from '$lib/components/UserDisplay.svelte';
let { data }: PageProps = $props();
let edit = $state(false);
</script>
<div class="flex justify-between">
<h2>{data.akti?.title} <span class="text-xs text-gray-400">v{data.akti.version}</span></h2>
{#if data.session?.user?.id === data.akti.author?.id && data.akti.author?.id}
<Button onclick={() => (edit = !edit)} color={edit ? 'gray' : 'primary'}>
{#if edit}
<CloseOutline class="shrink-0 h-6 w-6" />
{:else}
<EditOutline class="shrink-0 h-6 w-6 -mr-0.5 ml-0.5" />
{/if}
</Button>
{:else}
<div class="flex gap-5 items-center">
<p>gschribe vo:</p>
<UserDisplay user={data.akti.author} />
</div>
{/if}
</div>
{#if edit}
<AktiEditor akti={data.akti} />
{:else}
<div class="p-5 my-5 bg-gray-200 rounded-md">
<h3 class="mb-2">Zämefassig</h3>
<p>{data.akti.summary}</p>
</div>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html data.akti.body}
{/if}