Compare commits

...

14 commits
v0.3.4 ... main

Author SHA1 Message Date
4cbf1ca7c3
ci: set fetch-depth to 0
All checks were successful
Visitenbuch CI / test (push) Successful in 2m21s
Visitenbuch CI / release (push) Has been skipped
2024-05-24 17:49:06 +02:00
297f5cfa92
update README
All checks were successful
Visitenbuch CI / test (push) Successful in 2m20s
Visitenbuch CI / release (push) Has been skipped
2024-05-21 03:58:13 +02:00
8179c83383
update README
All checks were successful
Visitenbuch CI / test (push) Successful in 2m26s
Visitenbuch CI / release (push) Has been skipped
2024-05-20 22:59:27 +02:00
b696ee8894
chore(release): release v0.3.5
All checks were successful
Visitenbuch CI / test (push) Successful in 2m33s
Visitenbuch CI / release (push) Successful in 6m11s
2024-05-20 15:11:32 +02:00
03f6c58482
fix: remove test route 2024-05-19 23:37:33 +02:00
d746e4787d
feat: add optional Keycloak endpoint config 2024-05-19 19:01:53 +02:00
47f0a08ea3
fix: add dumb-init to docker image
All checks were successful
Visitenbuch CI / test (push) Successful in 2m21s
Visitenbuch CI / release (push) Has been skipped
2024-05-19 15:45:21 +02:00
2a4bda70c6
fix: allow multiple date filters 2024-05-19 15:36:59 +02:00
882ae66a6a
chore: remove unused zod-form-data dependency 2024-05-18 19:10:17 +02:00
98c62ac460
fix!: ensure category, room and station names are unique 2024-05-18 16:35:42 +02:00
ad796dcb57
feat: focus filter bar when pressing F
All checks were successful
Visitenbuch CI / test (push) Successful in 2m21s
Visitenbuch CI / release (push) Has been skipped
2024-05-18 16:30:06 +02:00
9ed5f15b9e
fix: Filterbar does not exclude present filters from URL, text filters dont confirm when defocused 2024-05-18 16:10:31 +02:00
f4f03ab491
fix: humanDate capitalization 2024-05-18 02:00:42 +02:00
34e54fa4af
fix: dont create entry executions if entry is only postponed 2024-05-18 01:33:18 +02:00
22 changed files with 190 additions and 61 deletions

View file

@ -68,7 +68,7 @@ jobs:
- name: 👁️ Checkout repository - name: 👁️ Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 1 # important to fetch tag logs fetch-depth: 0 # important to fetch tag logs
- name: 📦 pnpm install - name: 📦 pnpm install
run: pnpm install run: pnpm install

View file

@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v0.3.5](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.3.4..v0.3.5) - 2024-05-20
### 🚀 Features
- Focus filter bar when pressing F - ([ad796dc](https://code.thetadev.de/HSA/Visitenbuch/commit/ad796dcb578b79b566559d1c22c99f0231a03251))
- Add optional Keycloak endpoint config - ([d746e47](https://code.thetadev.de/HSA/Visitenbuch/commit/d746e4787d70080bbc22b37263bbfa695c1a7d72))
### 🐛 Bug Fixes
- Dont create entry executions if entry is only postponed - ([34e54fa](https://code.thetadev.de/HSA/Visitenbuch/commit/34e54fa4afdf17e7258cbc5ccac5a3d094ee161b))
- HumanDate capitalization - ([f4f03ab](https://code.thetadev.de/HSA/Visitenbuch/commit/f4f03ab4914f850b15acf7bb39da34b1abb587a7))
- Filterbar does not exclude present filters from URL, text filters dont confirm when defocused - ([9ed5f15](https://code.thetadev.de/HSA/Visitenbuch/commit/9ed5f15b9ef237cc400b069928baeb920b2d3681))
- [**breaking**] Ensure category, room and station names are unique - ([98c62ac](https://code.thetadev.de/HSA/Visitenbuch/commit/98c62ac4603fa6d7c97e1a439f613379db7a2587))
- Allow multiple date filters - ([2a4bda7](https://code.thetadev.de/HSA/Visitenbuch/commit/2a4bda70c6cfd85b4a32989a2e19ba718cc7717e))
- Add dumb-init to docker image - ([47f0a08](https://code.thetadev.de/HSA/Visitenbuch/commit/47f0a08ea3dbd8b1721a11c52b36c42ad56e8e29))
- Remove test route - ([03f6c58](https://code.thetadev.de/HSA/Visitenbuch/commit/03f6c5848201eff02999c6b9323ec1515d68fd5a))
### ⚙️ Miscellaneous Tasks
- Remove unused zod-form-data dependency - ([882ae66](https://code.thetadev.de/HSA/Visitenbuch/commit/882ae66a6a137259388525df2c91b8e1ed924d86))
## [v0.3.4](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.3.3..v0.3.4) - 2024-05-16 ## [v0.3.4](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.3.3..v0.3.4) - 2024-05-16
### 🚀 Features ### 🚀 Features

View file

@ -7,7 +7,8 @@ COPY package.json pnpm-lock.yaml run/entrypoint.sh ./
COPY prisma ./prisma COPY prisma ./prisma
# Setup pnpm, install Prisma CLI (for generating client) and install dependencies # Setup pnpm, install Prisma CLI (for generating client) and install dependencies
RUN npm config set update-notifier false && \ RUN apk add dumb-init && \
npm config set update-notifier false && \
corepack enable && \ corepack enable && \
pnpm i --prod && \ pnpm i --prod && \
pnpm audit fix && \ pnpm audit fix && \

View file

@ -4,21 +4,34 @@ for the university hospital in Augsburg
## Development ## Development
The project template was created using [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). The project template was created using
[`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: Once you've created a project and installed dependencies with `npm install` (or
`pnpm install` or `yarn`), start a development server:
```bash ```bash
npm run dev pnpm run dev
# or start the server and open the app in a new browser tab # or start the server and open the app in a new browser tab
npm run dev -- --open pnpm run dev -- --open
``` ```
### Handle the prisma ORM ### Test environment
Copy the `.env.example` file to `.env` to get access to the required configuration
variables.
The project depends on a PostgreSQL database and an OIDC authentication server. You can
setup both using the `run/db_up.sh` script. This creates a new testing environment using
docker-compose and fills the test database with mock data.
### Use the Pisma ORM
If you apply changes to the database scheme, you have to create a new migration to apply
these changes to the database.
```bash ```bash
./run/db_up.sh # Start the docker container, create a new database and run migrations + insert test data
npx prisma migrate dev --name my_migration --create-only # Create a new migration npx prisma migrate dev --name my_migration --create-only # Create a new migration
npx prisma migrate dev # Apply migrations to the database npx prisma migrate dev # Apply migrations to the database
``` ```
@ -28,12 +41,20 @@ npx prisma migrate dev # Apply migrations to the database
To create a production version of your app: To create a production version of your app:
```bash ```bash
npm run build pnpm run build
``` ```
You can preview the production build with `npm run preview`. You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. > To deploy your app, you may need to install an
> [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
### Test
```bash
pnpm test # Unit- und Integrationstests
pnpm test:e2e # End2End-Tests
```
### Release ### Release
@ -42,3 +63,10 @@ To release a new version, tun the release script with the new version as a param
```bash ```bash
./release.sh 1.0.0 ./release.sh 1.0.0
``` ```
### Building docker image
```bash
pnpm run build
docker build -t thetadev256/visitenbuch .
```

View file

@ -1,6 +1,6 @@
{ {
"name": "visitenbuch", "name": "visitenbuch",
"version": "0.3.4", "version": "0.3.5",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -28,8 +28,7 @@
"qs": "^6.12.1", "qs": "^6.12.1",
"set-cookie-parser": "^2.6.0", "set-cookie-parser": "^2.6.0",
"svelte-floating-ui": "^1.5.8", "svelte-floating-ui": "^1.5.8",
"zod": "^3.23.8", "zod": "^3.23.8"
"zod-form-data": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",

View file

@ -41,9 +41,6 @@ dependencies:
zod: zod:
specifier: ^3.23.8 specifier: ^3.23.8
version: 3.23.8 version: 3.23.8
zod-form-data:
specifier: ^2.0.2
version: 2.0.2(zod@3.23.8)
devDependencies: devDependencies:
'@faker-js/faker': '@faker-js/faker':
@ -5961,14 +5958,6 @@ packages:
dev: true dev: true
optional: true optional: true
/zod-form-data@2.0.2(zod@3.23.8):
resolution: {integrity: sha512-sKTi+k0fvkxdakD0V5rq+9WVJA3cuTQUfEmNqvHrTzPLvjfLmkkBLfR0ed3qOi9MScJXTHIDH/jUNnEJ3CBX4g==}
peerDependencies:
zod: '>= 3.11.0'
dependencies:
zod: 3.23.8
dev: false
/zod-to-json-schema@3.23.0(zod@3.23.8): /zod-to-json-schema@3.23.0(zod@3.23.8):
resolution: {integrity: sha512-az0uJ243PxsRIa2x1WmNE/pnuA05gUq/JB8Lwe1EDCCL/Fz9MgjYQ0fPlyc2Tcv6aF2ZA7WM5TWaRZVEFaAIag==} resolution: {integrity: sha512-az0uJ243PxsRIa2x1WmNE/pnuA05gUq/JB8Lwe1EDCCL/Fz9MgjYQ0fPlyc2Tcv6aF2ZA7WM5TWaRZVEFaAIag==}
requiresBuild: true requiresBuild: true

View file

@ -0,0 +1,16 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `categories` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[name]` on the table `rooms` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[name]` on the table `stations` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "categories_name_key" ON "categories"("name");
-- CreateIndex
CREATE UNIQUE INDEX "rooms_name_key" ON "rooms"("name");
-- CreateIndex
CREATE UNIQUE INDEX "stations_name_key" ON "stations"("name");

View file

@ -50,7 +50,7 @@ model User {
// Hospital station // Hospital station
model Station { model Station {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String @unique
Room Room[] Room Room[]
hidden Boolean @default(false) hidden Boolean @default(false)
@ -60,7 +60,7 @@ model Station {
// Hospital room // Hospital room
model Room { model Room {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String @unique
station Station @relation(fields: [station_id], references: [id], onDelete: Restrict) station Station @relation(fields: [station_id], references: [id], onDelete: Restrict)
station_id Int station_id Int
Patient Patient[] Patient Patient[]
@ -90,7 +90,7 @@ model Patient {
// Entry category (e.g. Blood test, Exams, ...) // Entry category (e.g. Blood test, Exams, ...)
model Category { model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String @unique
color String? color String?
description String? description String?
EntryVersion EntryVersion[] EntryVersion EntryVersion[]

View file

@ -1,4 +1,4 @@
#!/bin/sh #!/usr/bin/dumb-init /bin/sh
set -e set -e
# Migrate database before starting server # Migrate database before starting server
npx prisma migrate deploy npx prisma migrate deploy

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { mdiClose } from "@mdi/js"; import { mdiClose } from "@mdi/js";
import { onMount } from "svelte";
import { Debouncer } from "$lib/shared/util"; import { Debouncer } from "$lib/shared/util";
@ -192,8 +193,23 @@
searchDebounce.now(); searchDebounce.now();
} }
} }
function onWindowKeyup(e: KeyboardEvent): void {
// Dont catch keybinds when inputting text
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === "f") {
e.preventDefault();
focusInput();
}
}
onMount(() => {
if (hiddenIds.size === 0) activeFilters = activeFilters;
});
</script> </script>
<svelte:window on:keyup={onWindowKeyup} />
<div class="filterbar-outer"> <div class="filterbar-outer">
<div class="filterbar-inner input input-sm input-bordered"> <div class="filterbar-inner input input-sm input-bordered">
{#each activeFilters as fdata, i} {#each activeFilters as fdata, i}

View file

@ -41,6 +41,15 @@
} }
} : undefined; } : undefined;
function acceptTextInput(e: Event): void {
// @ts-expect-error Event is from HTML input
if (e.target?.value) {
// @ts-expect-error Input value is checked
fdata.selection = { id: null, name: e.target.value };
}
stopEditing(true);
}
$: if (fdata.editing && autocomplete) { $: if (fdata.editing && autocomplete) {
autocomplete.open(); autocomplete.open();
} }
@ -118,14 +127,10 @@
}} }}
on:keypress={(e) => { on:keypress={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
// @ts-expect-error Input value is checked acceptTextInput(e);
if (e.target?.value) {
// @ts-expect-error Input value is checked
fdata.selection = { id: null, name: e.target.value };
}
stopEditing(true);
} }
}} }}
on:blur={acceptTextInput}
/> />
{/if} {/if}
{:else} {:else}

View file

@ -1,6 +1,21 @@
<script lang="ts">
import { humanDate } from "$lib/shared/util";
function dateIn(n: number): string {
const date = new Date();
date.setDate(date.getDate() + n);
return humanDate(date);
}
</script>
<div class="join"> <div class="join">
{#each { length: 4 } as _, i} {#each { length: 4 } as _, i}
<button name="todo" class="join-item btn btn-sm" type="submit" value={i}> <button
name="todo"
class="join-item btn btn-sm"
title={i > 0 ? `Eintrag auf ${dateIn(i)} verschieben` : undefined}
type="submit"
value={i}>
{#if i === 0} {#if i === 0}
Notiz Notiz
{:else} {:else}

View file

@ -24,6 +24,16 @@ export const AUTH_CFG: AuthConfig = {
clientId: env.KEYCLOAK_CLIENT_ID, clientId: env.KEYCLOAK_CLIENT_ID,
clientSecret: env.KEYCLOAK_CLIENT_SECRET, clientSecret: env.KEYCLOAK_CLIENT_SECRET,
issuer: env.KEYCLOAK_ISSUER, issuer: env.KEYCLOAK_ISSUER,
/*
Optional manual OIDC endpoint config.
Normally this is configured via the issuer URL
(KEYCLOAK_ISSUER/.well-known/openid-configuration),
but if the OIDC server is available under a different
internal domain, these variables must be manually set
*/
authorization: env.KEYCLOAK_EP_AUTHORIZATION,
token: env.KEYCLOAK_EP_TOKEN,
userinfo: env.KEYCLOAK_EP_USERINFO,
}), }),
], ],
session: { session: {

View file

@ -287,6 +287,25 @@ left join stations s on s.id = r.station_id`,
qb.addFilterClause(`ev.date <= ${qb.pvar()}`, dateRange.end); qb.addFilterClause(`ev.date <= ${qb.pvar()}`, dateRange.end);
} }
}); });
const dfClauses: string[] = [];
const dfParams: Date[] = [];
filterListToArray(filter.date).forEach((itm) => {
const dateRange = DateRange.parse(itm, true);
const cl = [];
if (dateRange?.start) {
cl.push(`ev.date >= ${qb.pvar()}`);
dfParams.push(dateRange.start);
}
if (dateRange?.end) {
cl.push(`ev.date <= ${qb.pvar()}`);
dfParams.push(dateRange.end);
}
dfClauses.push(cl.join(" and "));
});
if (dfClauses.length > 0) {
qb.addFilterClause(dfClauses.join(" or "), ...dfParams);
}
} }
const SORT_FIELDS: Record<string, string[]> = { const SORT_FIELDS: Record<string, string[]> = {

View file

@ -130,6 +130,10 @@ class SearchQueryComponents {
* Supported search syntax: * Supported search syntax:
* - Negative query `-word` * - Negative query `-word`
* - Exact query `"word"` * - Exact query `"word"`
*
* The last word in a search query is prefix-matched (i.e.
* the search returns all results starting with the given characters).
* This allows for meaningful results in Search-as-you-type applications.
*/ */
export function parseSearchQuery(q: string): SearchQueryComponents { export function parseSearchQuery(q: string): SearchQueryComponents {
const regexpParts = /(-)?(?:"([^"]*)"|([^"\s]+))(?:\s|$)/g; const regexpParts = /(-)?(?:"([^"]*)"|([^"\s]+))(?:\s|$)/g;

View file

@ -77,6 +77,13 @@ function dateDiffInDays(a: Date, b: Date): number {
return Math.round((ts2 - ts1) / MS_PER_DAY); return Math.round((ts2 - ts1) / MS_PER_DAY);
} }
/** Format a date with a human-readable format
* - If the date is within +/- 3 days, output a textual format ("heute", "morgen", "vor 2 Tagen")
* - Otherwise, format it as DD.MM.YYYY (or DD.MM.YYYY, hh:mm if `time=true`)
*
* @param [time=false] Enable time display
* @param [cap=false] Enable capitalized format
*/
export function humanDate(date: Date | string, time = false, cap = false): string { export function humanDate(date: Date | string, time = false, cap = false): string {
const now = new Date(); const now = new Date();
const dt = coerceDate(date); const dt = coerceDate(date);
@ -96,7 +103,7 @@ export function humanDate(date: Date | string, time = false, cap = false): strin
if (diffDays !== 0) { if (diffDays !== 0) {
if (diffDays === 1) return outstr("morgen"); if (diffDays === 1) return outstr("morgen");
if (diffDays === -1) return outstr("gestern"); if (diffDays === -1) return outstr("gestern");
return intl.format(diffDays, "day"); return outstr(intl.format(diffDays, "day"));
} }
if (time) { if (time) {

View file

@ -158,7 +158,11 @@ export function defaultVisitUrl(): string {
}, URL_VISIT); }, URL_VISIT);
} }
export async function moveEntryTodoDate(id: number, nTodoDays: number, init?: TRPCClientInit) { export async function moveEntryTodoDate(
id:number,
nTodoDays: number,
init?: TRPCClientInit,
): Promise<Date | null> {
if (nTodoDays > 0) { if (nTodoDays > 0) {
const entry = await trpc(init).entry.get.query(id); const entry = await trpc(init).entry.get.query(id);
const newDate = new Date(); const newDate = new Date();
@ -171,5 +175,7 @@ export async function moveEntryTodoDate(id: number, nTodoDays: number, init?: TR
date: utcDateToYMD(newDate), date: utcDateToYMD(newDate),
}, },
}); });
return newDate;
} }
return null;
} }

View file

@ -5,7 +5,7 @@ import { superValidate, message } from "sveltekit-superforms";
import { ZUrlEntityId } from "$lib/shared/model/validation"; import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import { loadWrap, moveEntryTodoDate } from "$lib/shared/util"; import { humanDate, loadWrap, moveEntryTodoDate } from "$lib/shared/util";
import { SchemaNewExecution } from "./schema"; import { SchemaNewExecution } from "./schema";
@ -22,18 +22,21 @@ export const actions: Actions = {
const done = todoDays === null; const done = todoDays === null;
const nTodoDays = todoDays ? parseInt(todoDays.toString()) : 0; const nTodoDays = todoDays ? parseInt(todoDays.toString()) : 0;
await loadWrap(async () => { if (form.data.text.length > 0) {
await trpc(event).entry.newExecution.mutate({ await trpc(event).entry.newExecution.mutate({
id, id,
old_execution_id: form.data.old_execution_id, old_execution_id: form.data.old_execution_id,
execution: { text: form.data.text, done: todoDays === null }, execution: { text: form.data.text, done: todoDays === null },
}); });
await moveEntryTodoDate(id, nTodoDays, event);
});
if (nTodoDays > 0) {
return message(form, `Eintrag um ${nTodoDays} Tage in die Zukunft verschoben`);
} }
const newTodoDate = await moveEntryTodoDate(id, nTodoDays, event);
if (newTodoDate) {
return message(form, `Eintrag auf ${humanDate(newTodoDate)} verschoben`);
}
if (form.data.text.length > 0) {
return message(form, done ? "Eintrag erledigt" : "Eintrag mit Notiz versehen"); return message(form, done ? "Eintrag erledigt" : "Eintrag mit Notiz versehen");
}
return { form };
}), }),
}; };

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import { superForm } from "sveltekit-superforms"; import { superForm } from "sveltekit-superforms";
@ -33,7 +32,7 @@
label="Eintrag erledigen" label="Eintrag erledigen"
bind:value={$form.text} bind:value={$form.text}
> >
<div class="row c-vlight gap-2"> <div class="row c-vlight gap-2 flex-wrap">
<button class="btn btn-sm btn-primary" type="submit">Erledigt</button> <button class="btn btn-sm btn-primary" type="submit">Erledigt</button>
<EntryTodoButton /> <EntryTodoButton />
</div> </div>

View file

@ -5,7 +5,7 @@ import { superValidate } from "sveltekit-superforms";
import { ZUrlEntityId } from "$lib/shared/model/validation"; import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import { loadWrap, moveEntryTodoDate } from "$lib/shared/util"; import { loadWrap } from "$lib/shared/util";
import { SchemaNewExecution } from "../schema"; import { SchemaNewExecution } from "../schema";
@ -29,9 +29,6 @@ export const actions: Actions = {
old_execution_id: form.data.old_execution_id, old_execution_id: form.data.old_execution_id,
}); });
const nTodoDays = todoDays ? parseInt(todoDays.toString()) : 0;
await moveEntryTodoDate(id, nTodoDays, event);
redirect(302, `/entry/${id}`); redirect(302, `/entry/${id}`);
}), }),
}; };

View file

@ -7,7 +7,6 @@
import { superformConfig } from "$lib/shared/util"; import { superformConfig } from "$lib/shared/util";
import EntryBody from "$lib/components/entry/EntryBody.svelte"; import EntryBody from "$lib/components/entry/EntryBody.svelte";
import EntryTodoButton from "$lib/components/ui/EntryTodoButton.svelte";
import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte"; import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte";
import { SchemaNewExecution } from "../schema"; import { SchemaNewExecution } from "../schema";
@ -32,7 +31,9 @@
> >
<div class="row c-vlight gap-2"> <div class="row c-vlight gap-2">
<button class="btn btn-sm btn-primary" type="submit">Speichern</button> <button class="btn btn-sm btn-primary" type="submit">Speichern</button>
<EntryTodoButton /> <button name="todo" class="join-item btn btn-sm" type="submit" value="0">
Notiz
</button>
</div> </div>
</MarkdownInput> </MarkdownInput>
<input name="old_execution_id" type="hidden" bind:value={$form.old_execution_id} /> <input name="old_execution_id" type="hidden" bind:value={$form.old_execution_id} />

View file

@ -1,8 +0,0 @@
<script lang="ts">
import { toast } from "@zerodevx/svelte-toast";
</script>
<div>
<button class="btn" on:click={() => toast.push({ msg: "Hello" })}>Ok</button>
<button class="btn" on:click={() => toast.push({ msg: "Error", classes: ["toast-error"] })}>Error</button>
</div>