Compare commits
8 commits
d9167dcd47
...
22b74987e5
Author | SHA1 | Date | |
---|---|---|---|
22b74987e5 | |||
a430f55b2e | |||
2b6b3338d7 | |||
f79882ebf9 | |||
3f0eba7a22 | |||
e60a5b0162 | |||
39973c0a83 | |||
0a7aaed60d |
30 changed files with 1254 additions and 400 deletions
|
@ -15,6 +15,14 @@ npm run dev
|
||||||
npm run dev -- --open
|
npm run dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Handle the prisma ORM
|
||||||
|
|
||||||
|
```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 # Apply migrations to the database
|
||||||
|
```
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
To create a production version of your app:
|
To create a production version of your app:
|
||||||
|
|
40
package.json
40
package.json
|
@ -18,49 +18,53 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.27.0",
|
"@auth/core": "^0.27.0",
|
||||||
"@auth/sveltekit": "^0.13.0",
|
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@prisma/client": "^5.9.1",
|
"@prisma/client": "^5.10.2",
|
||||||
"isomorphic-dompurify": "^2.3.0",
|
"diff": "^5.2.0",
|
||||||
|
"isomorphic-dompurify": "^2.4.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"set-cookie-parser": "^2.6.0",
|
||||||
"svelte-floating-ui": "^1.5.8",
|
"svelte-floating-ui": "^1.5.8",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"zod-form-data": "^2.0.2"
|
"zod-form-data": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@playwright/test": "^1.41.2",
|
"@playwright/test": "^1.42.0",
|
||||||
"@sveltejs/adapter-node": "^4.0.1",
|
"@sveltejs/adapter-node": "^4.0.1",
|
||||||
"@sveltejs/kit": "^2.5.0",
|
"@sveltejs/kit": "^2.5.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@trpc/client": "^10.45.1",
|
"@trpc/client": "^10.45.1",
|
||||||
"@trpc/server": "^10.45.1",
|
"@trpc/server": "^10.45.1",
|
||||||
"@types/node": "^20.11.19",
|
"@types/diff": "^5.0.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.1",
|
"@types/node": "^20.11.21",
|
||||||
"@typescript-eslint/parser": "^7.0.1",
|
"@types/set-cookie-parser": "^2.4.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||||
|
"@typescript-eslint/parser": "^7.1.0",
|
||||||
"autoprefixer": "^10.4.17",
|
"autoprefixer": "^10.4.17",
|
||||||
"daisyui": "^4.7.2",
|
"daisyui": "^4.7.2",
|
||||||
"eslint": "^8.56.0",
|
"dotenv": "^16.4.5",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-no-relative-import-paths": "^1.5.3",
|
"eslint-plugin-no-relative-import-paths": "^1.5.3",
|
||||||
"eslint-plugin-svelte": "^2.35.1",
|
"eslint-plugin-svelte": "^2.35.1",
|
||||||
"postcss-import": "^16.0.1",
|
"postcss-import": "^16.0.1",
|
||||||
"postcss-nesting": "^12.0.2",
|
"postcss-nesting": "^12.0.4",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-svelte": "^3.2.1",
|
"prettier-plugin-svelte": "^3.2.2",
|
||||||
"prisma": "^5.9.1",
|
"prisma": "^5.10.2",
|
||||||
"svelte": "^4.2.11",
|
"svelte": "^4.2.12",
|
||||||
"svelte-check": "^3.6.4",
|
"svelte-check": "^3.6.5",
|
||||||
"sveltekit-superforms": "^2.3.0",
|
"sveltekit-superforms": "^2.6.2",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"trpc-sveltekit": "^3.5.26",
|
"trpc-sveltekit": "^3.5.27",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.1.3",
|
"vite": "^5.1.4",
|
||||||
"vitest": "^1.3.0"
|
"vitest": "^1.3.1"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|
897
pnpm-lock.yaml
897
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -33,5 +33,5 @@ EXECUTE PROCEDURE update_entry_tsvec ();
|
||||||
ALTER TABLE patients
|
ALTER TABLE patients
|
||||||
ADD COLUMN full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;
|
ADD COLUMN full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;
|
||||||
|
|
||||||
CREATE INDEX entries_tsvec ON entries USING GIN (tsvec);
|
CREATE INDEX entries_tsvec_idx ON entries USING GIN (tsvec);
|
||||||
CREATE INDEX patients_full_name ON patients USING gin (full_name gin_trgm_ops);
|
CREATE INDEX patients_full_name ON patients USING gin (full_name gin_trgm_ops);
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
CREATE COLLATION NATURAL_CI (provider = icu, locale = 'en-u-kn-true');
|
||||||
|
|
||||||
|
ALTER TABLE stations ALTER COLUMN NAME TYPE TEXT COLLATE NATURAL_CI;
|
||||||
|
ALTER TABLE rooms ALTER COLUMN NAME TYPE TEXT COLLATE NATURAL_CI;
|
||||||
|
ALTER TABLE categories ALTER COLUMN NAME TYPE TEXT COLLATE NATURAL_CI;
|
|
@ -108,6 +108,7 @@ model Entry {
|
||||||
tsvec Unsupported("tsvector")?
|
tsvec Unsupported("tsvector")?
|
||||||
|
|
||||||
@@map("entries")
|
@@map("entries")
|
||||||
|
@@index([tsvec], type: Gin)
|
||||||
}
|
}
|
||||||
|
|
||||||
model EntryVersion {
|
model EntryVersion {
|
||||||
|
|
|
@ -2,13 +2,14 @@
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
echo 'Waiting for database to be ready...'
|
echo 'Waiting for database to be ready...'
|
||||||
"$DIR/wait-for-it.sh" "localhost:5432" -- echo 'Database is ready!'
|
"$DIR/wait-for-it.sh" "localhost:5432" -- echo 'Database is ready!'
|
||||||
|
|
||||||
# Create temporary test database
|
# Create temporary test database
|
||||||
docker-compose exec -u 999:999 db sh -e -c 'dropdb -f --if-exists test; createdb test'
|
docker compose exec -u 999:999 db sh -e -c 'dropdb -f --if-exists test; createdb test'
|
||||||
cd "$DIR/../"
|
cd "$DIR/../"
|
||||||
DATABASE_URL="postgresql://postgres:1234@localhost:5432/test?schema=public" npx prisma migrate reset --force
|
DATABASE_URL="postgresql://postgres:1234@localhost:5432/test?schema=public" npx prisma migrate reset --force
|
||||||
|
|
||||||
|
|
6
src/app.d.ts
vendored
6
src/app.d.ts
vendored
|
@ -1,9 +1,13 @@
|
||||||
|
import type { Session } from "@auth/core/types";
|
||||||
|
|
||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
interface Locals {
|
||||||
|
session: Session;
|
||||||
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
|
33
src/app.pcss
33
src/app.pcss
|
@ -17,6 +17,10 @@ button {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-1 {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.v-form-field > input {
|
.v-form-field > input {
|
||||||
@apply input input-bordered w-full max-w-xs;
|
@apply input input-bordered w-full max-w-xs;
|
||||||
}
|
}
|
||||||
|
@ -25,3 +29,32 @@ button {
|
||||||
.v-form-field > [aria-invalid="true"] {
|
.v-form-field > [aria-invalid="true"] {
|
||||||
@apply input-error;
|
@apply input-error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card2 {
|
||||||
|
@apply bg-base-200;
|
||||||
|
@apply rounded-xl;
|
||||||
|
@apply mb-8;
|
||||||
|
@apply flex flex-col;
|
||||||
|
@apply border-solid border-base-content/30 border-[1px];
|
||||||
|
|
||||||
|
.row {
|
||||||
|
@apply flex flex-row p-2;
|
||||||
|
@apply border-solid border-base-content/30 border-t-[1px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:first-child {
|
||||||
|
@apply rounded-t-xl border-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
@apply rounded-b-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-light {
|
||||||
|
@apply bg-base-content/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-primary {
|
||||||
|
@apply bg-primary text-primary-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -13,9 +13,10 @@ import { skAuthHandle } from "$lib/server/auth";
|
||||||
* auth mechanism)
|
* auth mechanism)
|
||||||
*/
|
*/
|
||||||
const authorization: Handle = async ({ event, resolve }) => {
|
const authorization: Handle = async ({ event, resolve }) => {
|
||||||
|
// Allowed pages without login: TRPC API (has its own auth hook), Auth.js internal
|
||||||
|
// pages and the login site
|
||||||
if (!/^\/(login|trpc)/.test(event.url.pathname)) {
|
if (!/^\/(login|trpc)/.test(event.url.pathname)) {
|
||||||
const session = await event.locals.getSession();
|
if (!event.locals.session) {
|
||||||
if (!session) {
|
|
||||||
const params = new URLSearchParams({ returnURL: event.url.pathname });
|
const params = new URLSearchParams({ returnURL: event.url.pathname });
|
||||||
redirect(303, "/login?" + params.toString());
|
redirect(303, "/login?" + params.toString());
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,15 @@
|
||||||
import { URL_ENTRIES } from "$lib/shared/constants";
|
import { URL_ENTRIES } from "$lib/shared/constants";
|
||||||
import type { Category } from "$lib/shared/model";
|
import type { Category } from "$lib/shared/model";
|
||||||
import { gotoEntityQuery } from "$lib/shared/util";
|
import { gotoEntityQuery } from "$lib/shared/util";
|
||||||
|
import { getTextColor, colorToHex, hexToColor } from "$lib/shared/util/colors";
|
||||||
|
|
||||||
export let category: Category;
|
export let category: Category;
|
||||||
export let baseUrl = URL_ENTRIES;
|
export let baseUrl = URL_ENTRIES;
|
||||||
|
|
||||||
|
$: textColor = category.color
|
||||||
|
? colorToHex(getTextColor(hexToColor(category.color)))
|
||||||
|
: null;
|
||||||
|
|
||||||
function onClick(e: MouseEvent) {
|
function onClick(e: MouseEvent) {
|
||||||
gotoEntityQuery(
|
gotoEntityQuery(
|
||||||
{
|
{
|
||||||
|
@ -22,6 +27,8 @@
|
||||||
<button
|
<button
|
||||||
class="badge ellipsis"
|
class="badge ellipsis"
|
||||||
class:badge-neutral={!category.color}
|
class:badge-neutral={!category.color}
|
||||||
style={category.color ? `background-color: ${category.color};` : undefined}
|
style={category.color
|
||||||
|
? `color: ${textColor}; background-color: #${category.color};`
|
||||||
|
: undefined}
|
||||||
on:click={onClick}>{category.name}</button
|
on:click={onClick}>{category.name}</button
|
||||||
>
|
>
|
||||||
|
|
23
src/lib/components/ui/Header.svelte
Normal file
23
src/lib/components/ui/Header.svelte
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { mdiChevronLeft } from "@mdi/js";
|
||||||
|
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
|
export let title = "";
|
||||||
|
export let backHref: string | undefined = undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-row">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{#if backHref}
|
||||||
|
<a href={backHref} class="btn btn-sm btn-circle btn-ghost">
|
||||||
|
<Icon path={mdiChevronLeft} size={1.8} />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h1 class="heading">{title}</h1>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<slot name="rightBtn" />
|
||||||
|
</div>
|
|
@ -1,12 +1,21 @@
|
||||||
import { SvelteKitAuth, type SvelteKitAuthConfig } from "@auth/sveltekit";
|
import {
|
||||||
import { Auth, raw, skipCSRFCheck } from "@auth/core";
|
Auth,
|
||||||
|
isAuthAction,
|
||||||
|
raw,
|
||||||
|
setEnvDefaults,
|
||||||
|
skipCSRFCheck,
|
||||||
|
type AuthConfig,
|
||||||
|
} from "@auth/core";
|
||||||
import Keycloak from "@auth/core/providers/keycloak";
|
import Keycloak from "@auth/core/providers/keycloak";
|
||||||
import { prisma } from "$lib/server/prisma";
|
import { prisma } from "$lib/server/prisma";
|
||||||
import { PrismaAdapter } from "$lib/server/authAdapter";
|
import { PrismaAdapter } from "$lib/server/authAdapter";
|
||||||
import { env } from "$env/dynamic/private";
|
import { env } from "$env/dynamic/private";
|
||||||
import { redirect, type RequestEvent } from "@sveltejs/kit";
|
import { redirect, type Handle, type RequestEvent } from "@sveltejs/kit";
|
||||||
|
import { parse } from "set-cookie-parser";
|
||||||
|
|
||||||
export const AUTH_CFG: SvelteKitAuthConfig = {
|
const AUTH_BASE_PATH: string = "/auth";
|
||||||
|
|
||||||
|
export const AUTH_CFG: AuthConfig = {
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
providers: [
|
providers: [
|
||||||
Keycloak({
|
Keycloak({
|
||||||
|
@ -36,10 +45,26 @@ export const AUTH_CFG: SvelteKitAuthConfig = {
|
||||||
return opt.session;
|
return opt.session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
basePath: AUTH_BASE_PATH,
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Info: the @auth/sveltekit library has currently an issue with building request URLs.
|
||||||
|
If the application is run in production mode, it always creates HTTPS URLs
|
||||||
|
for the internal auth.js requests which fails if the application is not running
|
||||||
|
with HTTPS. This is the reason for not using the library and implementing the
|
||||||
|
auth handler function myself.
|
||||||
|
|
||||||
|
Original source: https://github.com/nextauthjs/next-auth/blob/main/packages/frameworks-sveltekit/src/lib/actions.ts
|
||||||
|
ISC License
|
||||||
|
*/
|
||||||
|
|
||||||
|
function authjsUrl(event: RequestEvent, authjsEndpoint: string): string {
|
||||||
|
const url = event.url;
|
||||||
|
return url.protocol + "//" + url.host + AUTH_BASE_PATH + "/" + authjsEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
export async function makeAuthjsRequest(
|
export async function makeAuthjsRequest(
|
||||||
event: RequestEvent,
|
event: RequestEvent,
|
||||||
authjsEndpoint: string,
|
authjsEndpoint: string,
|
||||||
|
@ -48,11 +73,8 @@ export async function makeAuthjsRequest(
|
||||||
const headers = new Headers(event.request.headers);
|
const headers = new Headers(event.request.headers);
|
||||||
headers.set("Content-Type", "application/x-www-form-urlencoded");
|
headers.set("Content-Type", "application/x-www-form-urlencoded");
|
||||||
|
|
||||||
const url = event.url;
|
|
||||||
const baseUrl = url.protocol + "//" + url.host;
|
|
||||||
|
|
||||||
const body = new URLSearchParams(params);
|
const body = new URLSearchParams(params);
|
||||||
const req = new Request(baseUrl + authjsEndpoint, {
|
const req = new Request(authjsUrl(event, authjsEndpoint), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
@ -64,4 +86,38 @@ export async function makeAuthjsRequest(
|
||||||
return redirect(302, res.redirect ?? "");
|
return redirect(302, res.redirect ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const skAuthHandle = SvelteKitAuth(AUTH_CFG).handle;
|
export async function auth(event: RequestEvent) {
|
||||||
|
const { request: req } = event;
|
||||||
|
setEnvDefaults(env, AUTH_CFG);
|
||||||
|
|
||||||
|
const sessionUrl = authjsUrl(event, "session");
|
||||||
|
const request = new Request(sessionUrl, {
|
||||||
|
headers: { cookie: req.headers.get("cookie") ?? "" },
|
||||||
|
});
|
||||||
|
const response = await Auth(request, AUTH_CFG);
|
||||||
|
const authCookies = parse(response.headers.getSetCookie());
|
||||||
|
for (const cookie of authCookies) {
|
||||||
|
const { name, value, ...options } = cookie;
|
||||||
|
// @ts-expect-error - Review: SvelteKit and set-cookie-parser are mismatching
|
||||||
|
event.cookies.set(name, value, { path: "/", ...options });
|
||||||
|
}
|
||||||
|
const { status = 200 } = response;
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data || !Object.keys(data).length) return null;
|
||||||
|
if (status === 200) return data;
|
||||||
|
throw new Error(data.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skAuthHandle: Handle = async ({ event, resolve }) => {
|
||||||
|
const { url, request } = event;
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith(AUTH_BASE_PATH + "/") &&
|
||||||
|
isAuthAction(url.pathname.slice(AUTH_BASE_PATH.length + 1).split("/")[0])
|
||||||
|
) {
|
||||||
|
return Auth(request, AUTH_CFG);
|
||||||
|
} else {
|
||||||
|
event.locals.session = await auth(event);
|
||||||
|
}
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
|
|
|
@ -6,13 +6,11 @@ import { type inferAsyncReturnType, TRPCError } from "@trpc/server";
|
||||||
// hence the eslint-disable rule
|
// hence the eslint-disable rule
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
export async function createContext(event: RequestEvent) {
|
export async function createContext(event: RequestEvent) {
|
||||||
const session = await event.locals.getSession();
|
if (!event.locals.session?.user) {
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = ZUser.parse(session?.user);
|
const user = ZUser.parse(event.locals.session?.user);
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,6 +17,7 @@ import {
|
||||||
newEntryExecution,
|
newEntryExecution,
|
||||||
newEntryVersion,
|
newEntryVersion,
|
||||||
} from "$lib/server/query";
|
} from "$lib/server/query";
|
||||||
|
import { versionsDiff } from "$lib/shared/util/diff";
|
||||||
|
|
||||||
const ZEntityId = fields.EntityId();
|
const ZEntityId = fields.EntityId();
|
||||||
|
|
||||||
|
@ -38,6 +39,12 @@ export const entryRouter = t.router({
|
||||||
versions: t.procedure
|
versions: t.procedure
|
||||||
.input(ZEntityId)
|
.input(ZEntityId)
|
||||||
.query(async (opts) => trpcWrap(async () => getEntryVersions(opts.input))),
|
.query(async (opts) => trpcWrap(async () => getEntryVersions(opts.input))),
|
||||||
|
versionsDiff: t.procedure.input(ZEntityId).query(async (opts) =>
|
||||||
|
trpcWrap(async () => {
|
||||||
|
const versions = await getEntryVersions(opts.input);
|
||||||
|
return versionsDiff(versions);
|
||||||
|
})
|
||||||
|
),
|
||||||
executions: t.procedure
|
executions: t.procedure
|
||||||
.input(ZEntityId)
|
.input(ZEntityId)
|
||||||
.query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))),
|
.query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))),
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
import type { RequestEvent } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
export async function userId(event: RequestEvent): Promise<number> {
|
|
||||||
const sess = await event.locals.getSession();
|
|
||||||
const id = Number(sess?.user?.id);
|
|
||||||
if (id) {
|
|
||||||
return id;
|
|
||||||
} else {
|
|
||||||
// This should never happen, since unauthorized requests are caught by hooks.server.ts
|
|
||||||
throw new Error("no user id");
|
|
||||||
}
|
|
||||||
}
|
|
52
src/lib/shared/util/colors.test.ts
Normal file
52
src/lib/shared/util/colors.test.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { hexToColor, colorToHex } from "./colors";
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
{
|
||||||
|
hex: "#ffffff",
|
||||||
|
color: { r: 255, g: 255, b: 255 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "#000000",
|
||||||
|
color: { r: 0, g: 0, b: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "#ff0000",
|
||||||
|
color: { r: 255, g: 0, b: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "#00ff00",
|
||||||
|
color: { r: 0, g: 255, b: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "#0000ff",
|
||||||
|
color: { r: 0, g: 0, b: 255 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "#8ECAE6",
|
||||||
|
color: { r: 142, g: 202, b: 230 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "#219EBC",
|
||||||
|
color: { r: 33, g: 158, b: 188 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "#023047",
|
||||||
|
color: { r: 2, g: 48, b: 71 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "#FFB703",
|
||||||
|
color: { r: 255, g: 183, b: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "#FB8500",
|
||||||
|
color: { r: 251, g: 133, b: 0 },
|
||||||
|
},
|
||||||
|
])("color conversion", ({ hex, color }) => {
|
||||||
|
it("colorToHex", () => {
|
||||||
|
expect(colorToHex(color)).toBe(hex.toLowerCase());
|
||||||
|
});
|
||||||
|
it("hexToColor", () => {
|
||||||
|
expect(hexToColor(hex)).toStrictEqual(color);
|
||||||
|
});
|
||||||
|
});
|
72
src/lib/shared/util/colors.ts
Normal file
72
src/lib/shared/util/colors.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
// Source: https://css-tricks.com/nailing-the-perfect-contrast-between-light-text-and-a-background-image/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RGB color value (0-255)
|
||||||
|
*/
|
||||||
|
export type Color = {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WHITE: Color = { r: 255, g: 255, b: 255 };
|
||||||
|
const BLACK: Color = { r: 0, g: 0, b: 0 };
|
||||||
|
|
||||||
|
export function colorToHex(c: Color): string {
|
||||||
|
return "#" + ((1 << 24) | (c.r << 16) | (c.g << 8) | c.b).toString(16).slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToColor(s: string): Color {
|
||||||
|
const hexStr = s[0] === "#" ? s.substring(1) : s;
|
||||||
|
const c = parseInt(hexStr, 16);
|
||||||
|
return {
|
||||||
|
r: c >> 16,
|
||||||
|
g: (c >> 8) & 255,
|
||||||
|
b: c & 255,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContrast(color1: Color, color2: Color): number {
|
||||||
|
const color1_luminance = getLuminance(color1);
|
||||||
|
const color2_luminance = getLuminance(color2);
|
||||||
|
const lighterColorLuminance = Math.max(color1_luminance, color2_luminance);
|
||||||
|
const darkerColorLuminance = Math.min(color1_luminance, color2_luminance);
|
||||||
|
const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);
|
||||||
|
return contrast;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLuminance(c: Color): number {
|
||||||
|
return (
|
||||||
|
0.2126 * getLinearRGB(c.r) + 0.7152 * getLinearRGB(c.g) + 0.0722 * getLinearRGB(c.b)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convert_8bit_RGB_to_standard_RGB(primaryColor_8bit: number): number {
|
||||||
|
return primaryColor_8bit / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convert_standard_RGB_to_linear_RGB(primaryColor_sRGB: number): number {
|
||||||
|
const primaryColor_linear =
|
||||||
|
primaryColor_sRGB < 0.03928
|
||||||
|
? primaryColor_sRGB / 12.92
|
||||||
|
: Math.pow((primaryColor_sRGB + 0.055) / 1.055, 2.4);
|
||||||
|
return primaryColor_linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinearRGB(primaryColor_8bit: number): number {
|
||||||
|
// First convert from 8-bit rbg (0-255) to standard RGB (0-1)
|
||||||
|
const primaryColor_sRGB = convert_8bit_RGB_to_standard_RGB(primaryColor_8bit);
|
||||||
|
|
||||||
|
// Then convert from sRGB to linear RGB so we can use it to calculate luminance
|
||||||
|
const primaryColor_RGB_linear = convert_standard_RGB_to_linear_RGB(primaryColor_sRGB);
|
||||||
|
return primaryColor_RGB_linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the preferred text color for the given background color
|
||||||
|
*/
|
||||||
|
export function getTextColor(bgColor: Color): Color {
|
||||||
|
const cWhite = getContrast(bgColor, WHITE);
|
||||||
|
const cBlack = getContrast(bgColor, BLACK);
|
||||||
|
return cWhite > cBlack ? WHITE : BLACK;
|
||||||
|
}
|
110
src/lib/shared/util/diff.test.ts
Normal file
110
src/lib/shared/util/diff.test.ts
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import { test, expect } from "vitest";
|
||||||
|
import type { EntryVersion } from "$lib/shared/model";
|
||||||
|
import { CATEGORIES, U1, U2 } from "$tests/helpers/testdata";
|
||||||
|
import { versionsDiff } from "./diff";
|
||||||
|
|
||||||
|
test("versions diff", () => {
|
||||||
|
const versions: EntryVersion[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
author: U1,
|
||||||
|
category: CATEGORIES[0],
|
||||||
|
created_at: new Date(Date.UTC(2024, 1, 10, 10, 30)),
|
||||||
|
date: "2024-01-11",
|
||||||
|
priority: false,
|
||||||
|
text: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ getestet werden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
author: U2,
|
||||||
|
category: CATEGORIES[0],
|
||||||
|
created_at: new Date(Date.UTC(2024, 1, 10, 11, 30)),
|
||||||
|
date: "2024-01-12",
|
||||||
|
priority: true,
|
||||||
|
text: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
author: U1,
|
||||||
|
category: CATEGORIES[0],
|
||||||
|
created_at: new Date(Date.UTC(2024, 1, 10, 11, 31)),
|
||||||
|
date: "2024-01-12",
|
||||||
|
priority: true,
|
||||||
|
text: "10ml Blut abnehmen.\n\nDas Blut muss auf Lambda-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const diff = versionsDiff(versions);
|
||||||
|
expect(diff).toStrictEqual([
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
author: {
|
||||||
|
id: 1,
|
||||||
|
name: "Sven Schulz",
|
||||||
|
email: "sven.schulz@example.com",
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
id: 1,
|
||||||
|
name: "Laborabnahme",
|
||||||
|
description: "Blutabnahme zur Untersuchung im Labor",
|
||||||
|
color: "FF0000",
|
||||||
|
},
|
||||||
|
created_at: new Date("2024-02-10T11:31:00.000Z"),
|
||||||
|
date: "2024-01-12",
|
||||||
|
priority: true,
|
||||||
|
text: [
|
||||||
|
{
|
||||||
|
count: 38,
|
||||||
|
added: true,
|
||||||
|
removed: undefined,
|
||||||
|
value:
|
||||||
|
"10ml Blut abnehmen.\n\nDas Blut muss auf Lambda-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
author: {
|
||||||
|
id: 2,
|
||||||
|
name: "Sabrina Loewe",
|
||||||
|
email: "sabrina.loewe@example.com",
|
||||||
|
},
|
||||||
|
created_at: new Date("2024-02-10T11:30:00.000Z"),
|
||||||
|
category: undefined,
|
||||||
|
date: undefined,
|
||||||
|
priority: undefined,
|
||||||
|
text: [
|
||||||
|
{ count: 16, value: "10ml Blut abnehmen.\n\nDas Blut muss auf " },
|
||||||
|
{ count: 1, added: undefined, removed: true, value: "Lambda" },
|
||||||
|
{ count: 1, added: true, removed: undefined, value: "XYZ" },
|
||||||
|
{
|
||||||
|
count: 21,
|
||||||
|
value: "-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
author: {
|
||||||
|
id: 1,
|
||||||
|
name: "Sven Schulz",
|
||||||
|
email: "sven.schulz@example.com",
|
||||||
|
},
|
||||||
|
created_at: new Date("2024-02-10T10:30:00.000Z"),
|
||||||
|
category: undefined,
|
||||||
|
text: [
|
||||||
|
{ count: 17, value: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ" },
|
||||||
|
{ count: 2, added: undefined, removed: true, value: "-Erreger" },
|
||||||
|
{ count: 5, value: " getestet werden." },
|
||||||
|
{
|
||||||
|
count: 14,
|
||||||
|
added: undefined,
|
||||||
|
removed: true,
|
||||||
|
value: "\n\nHierfür ist das Labor Meier zuständig.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
date: "2024-01-11",
|
||||||
|
priority: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
46
src/lib/shared/util/diff.ts
Normal file
46
src/lib/shared/util/diff.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { diffWords } from "diff";
|
||||||
|
import type { EntryVersion, Category, Option, UserTag } from "$lib/shared/model";
|
||||||
|
|
||||||
|
export type EntryVersionChange = {
|
||||||
|
id: number;
|
||||||
|
author: UserTag;
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
text: Diff.Change[];
|
||||||
|
date?: string;
|
||||||
|
category?: Option<Category>;
|
||||||
|
priority?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function newOrUndef<T>(o: T, n: T): T | undefined {
|
||||||
|
return o === n ? undefined : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionsDiff(versions: EntryVersion[]): EntryVersionChange[] {
|
||||||
|
let prev = versions[versions.length - 1];
|
||||||
|
const changes: EntryVersionChange[] = [
|
||||||
|
{
|
||||||
|
...prev,
|
||||||
|
text: diffWords("", prev.text),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = versions.length - 2; i >= 0; i--) {
|
||||||
|
const v = versions[i];
|
||||||
|
const text = diffWords(prev.text, v.text);
|
||||||
|
|
||||||
|
changes.push({
|
||||||
|
id: v.id,
|
||||||
|
author: v.author,
|
||||||
|
created_at: v.created_at,
|
||||||
|
text,
|
||||||
|
date: newOrUndef(prev.date, v.date),
|
||||||
|
category: prev.category?.id === v.category?.id ? undefined : v.category,
|
||||||
|
priority: newOrUndef(prev.priority, v.priority),
|
||||||
|
});
|
||||||
|
|
||||||
|
prev = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { goto } from "$app/navigation";
|
||||||
import type { EntityQuery } from "$lib/shared/model";
|
import type { EntityQuery } from "$lib/shared/model";
|
||||||
import { TRPCClientError } from "@trpc/client";
|
import { TRPCClientError } from "@trpc/client";
|
||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
export function formatDate(date: Date | string, time = false): string {
|
export function formatDate(date: Date | string, time = false): string {
|
||||||
let dt = date;
|
let dt = date;
|
||||||
|
@ -25,6 +26,10 @@ export function formatDate(date: Date | string, time = false): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatBool(val: boolean): string {
|
||||||
|
return val ? "Ja" : "Nein";
|
||||||
|
}
|
||||||
|
|
||||||
export function getQueryUrl(q: EntityQuery, basePath: string): string {
|
export function getQueryUrl(q: EntityQuery, basePath: string): string {
|
||||||
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath;
|
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath;
|
||||||
return basePath + "/" + JSON.stringify(q);
|
return basePath + "/" + JSON.stringify(q);
|
||||||
|
@ -56,6 +61,8 @@ export async function loadWrap<T>(f: () => Promise<T>) {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof TRPCClientError) {
|
if (e instanceof TRPCClientError) {
|
||||||
error(e.data?.httpStatus ?? 500, e.message);
|
error(e.data?.httpStatus ?? 500, e.message);
|
||||||
|
} else if (e instanceof ZodError) {
|
||||||
|
error(400, e.message);
|
||||||
} else if (e instanceof Error) {
|
} else if (e instanceof Error) {
|
||||||
error(500, e.message);
|
error(500, e.message);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import NavLink from "$lib/components/ui/NavLink.svelte";
|
import NavLink from "$lib/components/ui/NavLink.svelte";
|
||||||
import { signOut } from "@auth/sveltekit/client";
|
|
||||||
import { mdiAccount, mdiHome } from "@mdi/js";
|
import { mdiAccount, mdiHome } from "@mdi/js";
|
||||||
|
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
|
|
@ -5,6 +5,9 @@
|
||||||
import RoomField from "$lib/components/table/RoomField.svelte";
|
import RoomField from "$lib/components/table/RoomField.svelte";
|
||||||
import CategoryField from "$lib/components/table/CategoryField.svelte";
|
import CategoryField from "$lib/components/table/CategoryField.svelte";
|
||||||
import Markdown from "$lib/components/ui/Markdown.svelte";
|
import Markdown from "$lib/components/ui/Markdown.svelte";
|
||||||
|
import Header from "$lib/components/ui/Header.svelte";
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import { mdiHistory } from "@mdi/js";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
</script>
|
</script>
|
||||||
|
@ -13,50 +16,57 @@
|
||||||
<title>Eintrag #{data.entry.id}</title>
|
<title>Eintrag #{data.entry.id}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1 class="heading">
|
<Header title="Eintrag #{data.entry.id}">
|
||||||
Eintrag #{data.entry.id}
|
|
||||||
{#if data.entry.current_version.category}
|
{#if data.entry.current_version.category}
|
||||||
<CategoryField category={data.entry.current_version.category} />
|
<CategoryField category={data.entry.current_version.category} />
|
||||||
{/if}
|
{/if}
|
||||||
</h1>
|
{#if data.entry.current_version.priority}
|
||||||
|
<div class="badge ellipsis badge-warning">Priorität</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="box">
|
<a
|
||||||
<p>
|
href="/entry/{data.entry.id}/versions"
|
||||||
|
class="btn btn-sm btn-primary ml-auto"
|
||||||
|
slot="rightBtn"
|
||||||
|
>
|
||||||
|
<Icon path={mdiHistory} />
|
||||||
|
<span class="hidden sm:inline">Versionen</span>
|
||||||
|
</a>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<div class="card2">
|
||||||
|
<div class="row c-light text-sm">Patient</div>
|
||||||
|
<div class="row items-center gap-2">
|
||||||
{#if data.entry.patient.room}
|
{#if data.entry.patient.room}
|
||||||
<RoomField room={data.entry.patient.room} />
|
<RoomField room={data.entry.patient.room} />
|
||||||
{/if}
|
{/if}
|
||||||
{data.entry.patient.first_name}
|
{data.entry.patient.first_name}
|
||||||
{data.entry.patient.last_name}
|
{data.entry.patient.last_name}
|
||||||
({data.entry.patient.age})
|
({data.entry.patient.age})
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box">
|
<div class="card2">
|
||||||
|
<div class="row c-light text-sm">Beschreibung</div>
|
||||||
|
<div class="row">
|
||||||
<p class="prose">
|
<p class="prose">
|
||||||
<Markdown src={data.entry.current_version.text} />
|
<Markdown src={data.entry.current_version.text} />
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.entry.execution}
|
{#if data.entry.execution}
|
||||||
<div class="box">
|
<div class="card2">
|
||||||
|
<div class="row c-light text-sm">
|
||||||
<p>
|
<p>
|
||||||
Erledigt am {formatDate(data.entry.execution.created_at, true)} von
|
Erledigt am {formatDate(data.entry.execution.created_at, true)} von
|
||||||
<UserField user={data.entry.execution.author} filterName="executor" />:
|
<UserField user={data.entry.execution.author} filterName="executor" />:
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
<p class="prose">
|
<p class="prose">
|
||||||
<Markdown src={data.entry.execution?.text} />
|
<Markdown src={data.entry.execution?.text} />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
p {
|
|
||||||
@apply my-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.box {
|
|
||||||
@apply rounded-xl border-solid border-2 border-base-content/40;
|
|
||||||
@apply px-2 my-4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -4,8 +4,10 @@ import { loadWrap } from "$lib/shared/util";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async (event) => {
|
export const load: PageLoad = async (event) => {
|
||||||
|
const entry = await loadWrap(async () => {
|
||||||
const id = ZUrlEntityId.parse(event.params.id);
|
const id = ZUrlEntityId.parse(event.params.id);
|
||||||
const entry = await loadWrap(async () => trpc(event).entry.get.query(id));
|
return trpc(event).entry.get.query(id);
|
||||||
|
});
|
||||||
|
|
||||||
return { entry };
|
return { entry };
|
||||||
};
|
};
|
||||||
|
|
71
src/routes/(app)/entry/[id]/versions/+page.svelte
Normal file
71
src/routes/(app)/entry/[id]/versions/+page.svelte
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import CategoryField from "$lib/components/table/CategoryField.svelte";
|
||||||
|
import UserField from "$lib/components/table/UserField.svelte";
|
||||||
|
import { formatBool, formatDate } from "$lib/shared/util";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
import Header from "$lib/components/ui/Header.svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
$: entryId = $page.params.id;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Eintrag #{entryId} - Versionen</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<Header title="Eintrag #{entryId} - Versionen" backHref="/entry/{entryId}" />
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
{#each data.versions as version}
|
||||||
|
<div class="card2">
|
||||||
|
<div class="row c-light text-sm">
|
||||||
|
<UserField user={version.author} />, {formatDate(version.created_at, true)}
|
||||||
|
</div>
|
||||||
|
{#if version.category}
|
||||||
|
<div class="row">
|
||||||
|
<div>Kategeorie</div>
|
||||||
|
<div><CategoryField category={version.category} /></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if version.text.length > 0}
|
||||||
|
<div class="row">
|
||||||
|
<div>Text</div>
|
||||||
|
<div class="whitespace-pre-wrap">
|
||||||
|
{#each version.text as change}
|
||||||
|
<span class:added={change.added} class:removed={change.removed}>
|
||||||
|
{change.value}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if version.date !== undefined}
|
||||||
|
<div class="row">
|
||||||
|
<div>Datum</div>
|
||||||
|
<div>{formatDate(version.date)}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if version.priority !== undefined}
|
||||||
|
<div class="row">
|
||||||
|
<div>Priorität</div>
|
||||||
|
<div>{formatBool(version.priority)}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
.row > div:first-child {
|
||||||
|
min-width: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.added {
|
||||||
|
@apply bg-success/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removed {
|
||||||
|
@apply bg-error/20;
|
||||||
|
}
|
||||||
|
</style>
|
11
src/routes/(app)/entry/[id]/versions/+page.ts
Normal file
11
src/routes/(app)/entry/[id]/versions/+page.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { ZUrlEntityId } from "$lib/shared/model/validation";
|
||||||
|
import { trpc } from "$lib/shared/trpc";
|
||||||
|
import { loadWrap } from "$lib/shared/util";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async (event) => {
|
||||||
|
const id = ZUrlEntityId.parse(event.params.id);
|
||||||
|
const versions = await loadWrap(async () => trpc(event).entry.versionsDiff.query(id));
|
||||||
|
|
||||||
|
return { versions };
|
||||||
|
};
|
|
@ -6,6 +6,6 @@ export const actions: Actions = {
|
||||||
default: async (event) => {
|
default: async (event) => {
|
||||||
const callbackUrl = baseUrl(event.url) + "/login?noAuto=1";
|
const callbackUrl = baseUrl(event.url) + "/login?noAuto=1";
|
||||||
|
|
||||||
return makeAuthjsRequest(event, "/auth/signout", { callbackUrl });
|
return makeAuthjsRequest(event, "signout", { callbackUrl });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,6 @@ import type { LayoutServerLoad } from "./$types";
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async (event) => {
|
export const load: LayoutServerLoad = async (event) => {
|
||||||
return {
|
return {
|
||||||
session: await event.locals.getSession(),
|
session: event.locals.session,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,11 +1,40 @@
|
||||||
import type { Actions } from "./$types";
|
import type { Actions, RequestEvent } from "./$types";
|
||||||
import { makeAuthjsRequest } from "$lib/server/auth";
|
import { makeAuthjsRequest } from "$lib/server/auth";
|
||||||
import { baseUrl } from "$lib/shared/util";
|
import { baseUrl } from "$lib/shared/util";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This cookie stores the timestamp of the last automatic login.
|
||||||
|
* It is used to prevent infinite redirect loops in case the authentication fails.
|
||||||
|
*/
|
||||||
|
const COOKIE_NAME = "autoLoginTs";
|
||||||
|
|
||||||
|
async function doLogin(event: RequestEvent) {
|
||||||
|
const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url);
|
||||||
|
|
||||||
|
return makeAuthjsRequest(event, "signin/keycloak", { callbackUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to login the user using OIDC
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
// the noAuto URL parameter disables auto-login
|
||||||
|
if (event.url.searchParams.get("noAuto")) return;
|
||||||
|
|
||||||
|
let autoLoginTs = null;
|
||||||
|
const autoLoginTsStr = event.cookies.get(COOKIE_NAME);
|
||||||
|
if (autoLoginTsStr) {
|
||||||
|
autoLoginTs = parseInt(autoLoginTsStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (!autoLoginTs || autoLoginTs + 15000 < now) {
|
||||||
|
event.cookies.set(COOKIE_NAME, now.toString(), { path: "/login" });
|
||||||
|
return doLogin(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async (event) => {
|
default: async (event) => {
|
||||||
const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url);
|
return doLogin(event);
|
||||||
|
|
||||||
return makeAuthjsRequest(event, "/auth/signin/keycloak", { callbackUrl });
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,37 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { signIn } from "@auth/sveltekit/client";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
const STORAGE_KEY = "autoLoginTs";
|
|
||||||
|
|
||||||
let callbackUrl: string;
|
|
||||||
$: if ($page.url) {
|
|
||||||
let u = new URL($page.url.protocol + "//" + $page.url.host);
|
|
||||||
u.pathname = $page.url.searchParams.get("returnURL") ?? "/";
|
|
||||||
callbackUrl = u.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client side auto-login / redirect
|
|
||||||
onMount(() => {
|
|
||||||
if (!$page.url.searchParams.get("noAuto")) {
|
|
||||||
// Keep track of last login time to avoid redirect loop if the login fails
|
|
||||||
const lastUpdateStr = sessionStorage.getItem(STORAGE_KEY);
|
|
||||||
const lastUpdate = lastUpdateStr ? parseInt(lastUpdateStr) : null;
|
|
||||||
const now = Date.now();
|
|
||||||
if (!lastUpdate || lastUpdate + 10000 < now) {
|
|
||||||
sessionStorage.setItem(STORAGE_KEY, now.toString());
|
|
||||||
|
|
||||||
if (!$page.data.session?.user) {
|
|
||||||
signIn("keycloak", { callbackUrl });
|
|
||||||
} else {
|
|
||||||
goto(callbackUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
|
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
|
||||||
|
|
Loading…
Reference in a new issue