40 Commits

Author SHA1 Message Date
schreifuchs aae537b312 chore: add docker-hub login to pipeline
Commit / ci (push) Successful in 10m42s
2026-04-05 10:32:22 +02:00
schreifuchs 19238edf57 Merge pull request 'Implement Pagination / Limit for the Dashboard' (#18) from issue-6 into main
Commit / ci (push) Waiting to run
Reviewed-on: #18
2026-04-05 10:13:26 +02:00
schreifuchs 0f8ad4a36a chore: better pre-commit checks
Commit / ci (push) Successful in 10m32s
2026-04-03 17:32:09 +02:00
schreifuchs 9fac4d0149 Merge pull request 'Fix Action Validation Error Handling' (#14) from issue-3 into main
Commit / ci (push) Waiting to run
Reviewed-on: #14
2026-04-03 17:27:28 +02:00
schreifuchs c71a28fd98 Merge pull request 'Fix Hacky Fallback in Auth Query' (#15) from issue-4 into main
Commit / ci (push) Waiting to run
Reviewed-on: #15
2026-04-03 17:27:09 +02:00
schreifuchs 3bced84749 Merge pull request 'Extend Auth.js Types Globally' (#20) from issue-7 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #20
2026-04-03 17:26:07 +02:00
schreifuchs 2c47870e0f revert: Docker base image to original
Commit / ci (push) Successful in 10m26s
PullRequest / publish (pull_request) Failing after 2m27s
2026-04-03 14:28:08 +02:00
schreifuchs 0de1cdfb8d revert: Docker base image to original
Commit / ci (push) Successful in 10m34s
PullRequest / publish (pull_request) Failing after 2m28s
2026-04-03 14:27:58 +02:00
schreifuchs cd6ba6df9a revert: Docker base image to original
Commit / ci (push) Successful in 10m35s
PullRequest / publish (pull_request) Failing after 2m12s
2026-04-03 14:27:41 +02:00
schreifuchs 6d86630b4a revert: Docker base image to original
Commit / ci (push) Successful in 10m24s
PullRequest / publish (pull_request) Failing after 2m15s
2026-04-03 14:27:24 +02:00
schreifuchs 8558e88a71 revert: Docker base image change on main
Commit / ci (push) Successful in 10m40s
2026-04-03 14:27:20 +02:00
schreifuchs 8c192ce8ab Merge pull request 'Clean up Redundant Imports' (#22) from issue-11 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #22
2026-04-03 14:09:40 +02:00
schreifuchs b064ccf5d6 Merge pull request 'Add Vitest + Svelte Testing Library' (#23) from issue-13 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #23
2026-04-03 14:09:29 +02:00
schreifuchs fdb8017087 ci: fix docker hub rate limit with ecr mirror
Commit / ci (push) Successful in 10m27s
PullRequest / publish (pull_request) Successful in 5m0s
2026-04-03 13:58:05 +02:00
schreifuchs 641524218e ci: fix docker hub rate limit with ecr mirror
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Successful in 4m8s
2026-04-03 13:57:44 +02:00
schreifuchs 89c86a2d32 ci: fix docker hub rate limit with ecr mirror
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Successful in 4m38s
2026-04-03 13:57:34 +02:00
schreifuchs af02de06b1 ci: fix docker hub rate limit with ecr mirror
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Successful in 4m14s
2026-04-03 13:57:24 +02:00
schreifuchs 15829e1b19 ci: fix docker hub rate limit with ecr mirror
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Successful in 5m6s
2026-04-03 13:57:14 +02:00
schreifuchs 9a49b9a29a refactor: resolve merge conflicts
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m29s
2026-04-03 13:54:52 +02:00
schreifuchs d839e9f178 chore: resolve merge conflicts
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m28s
2026-04-03 13:54:26 +02:00
schreifuchs 52ecbac1bd chore: resolve merge conflicts
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m13s
2026-04-03 13:54:04 +02:00
schreifuchs 97e11a4de7 refactor: resolve merge conflicts
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m28s
2026-04-03 13:53:43 +02:00
schreifuchs d6f6125204 ci: run tests in pipeline and fix docker rate limits
Commit / ci (push) Successful in 10m32s
PullRequest / publish (pull_request) Failing after 5m21s
2026-04-03 13:53:21 +02:00
schreifuchs beb790bed8 chore: resolve merge conflicts 2026-04-03 13:52:58 +02:00
schreifuchs 070638b2f1 refactor: resolve conflicts and use queries helper
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m21s
2026-04-03 13:52:40 +02:00
schreifuchs f58af022dc feat: implement endless scrolling 2026-04-03 13:52:13 +02:00
schreifuchs c1a0a5de6c Merge pull request 'Abstract Heavy Database Queries' (#24) from issue-10 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #24
2026-04-03 13:48:53 +02:00
schreifuchs 2140d06fb5 Merge pull request 'Parallelize Database Queries' (#19) from issue-5 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #19
2026-04-03 13:42:19 +02:00
schreifuchs 6e24b68a08 Merge pull request 'Move Server-Only Code to $lib/server' (#16) from issue-2 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #16
2026-04-03 13:40:06 +02:00
schreifuchs b3087aa9d4 test: add vitest and svelte testing library (resolves #13)
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 1m56s
2026-04-03 13:26:59 +02:00
schreifuchs c459d58a28 refactor: abstract heavy database queries (resolves #10)
Commit / ci (push) Successful in 10m34s
PullRequest / publish (pull_request) Failing after 2m17s
2026-04-03 13:26:55 +02:00
schreifuchs 005dc22a2e refactor: clean up redundant imports (resolves #11)
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m22s
2026-04-03 13:26:04 +02:00
schreifuchs 239bf163e8 fix: XSS Vulnerability (#17)
Commit / ci (push) Has been cancelled
Resolves #1

Reviewed-on: #17
2026-04-03 13:09:45 +02:00
schreifuchs 8483ab9e84 refactor: extend auth.js types globally (resolves #7)
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m41s
2026-04-03 13:06:47 +02:00
schreifuchs 16248416e7 perf: parallelize database queries (resolves #5)
Commit / ci (push) Successful in 10m40s
PullRequest / publish (pull_request) Failing after 2m23s
2026-04-03 13:06:33 +02:00
schreifuchs 4475879330 feat: implement limit for the dashboard (resolves #6)
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m10s
2026-04-03 13:06:11 +02:00
schreifuchs 7d9ff9ff2b refactor: move server-only code (resolves #2)
PullRequest / publish (pull_request) Failing after 2m22s
Commit / ci (push) Successful in 10m38s
2026-04-03 13:01:30 +02:00
schreifuchs 7492680457 fix: validation error handling (resolves #3)
Commit / ci (push) Successful in 10m34s
PullRequest / publish (pull_request) Failing after 2m36s
2026-04-03 13:00:14 +02:00
schreifuchs 85edb99e64 fix: auth query fallback (resolves #4)
Commit / ci (push) Successful in 10m29s
PullRequest / publish (pull_request) Failing after 2m28s
2026-04-03 13:00:11 +02:00
schreifuchs 2e16cf9d51 docs: add TODO.md with project review and refactoring tasks
Commit / ci (push) Successful in 10m32s
2026-04-03 12:31:11 +02:00
49 changed files with 948 additions and 127689 deletions
-2
View File
@@ -6,5 +6,3 @@ AUTH_NEXTCLOUD_ID=asdf
AUTH_NEXTCLOUD_SECRET=adsf
AUTH_NEXTCLOUD_ISSUER="https://cloud.schreifuchs.ch"
AUTH_TRUST_HOST=true
ECAMP_BASE_URL=http://localhost:3000/api
+6 -1
View File
@@ -3,7 +3,9 @@ name: Commit
on:
push:
# only trigger on branches, not on tags
branches: '**'
branches:
- 'main'
- 'dev'
jobs:
# Job 1: Lint and Test (Type Check)
@@ -34,3 +36,6 @@ jobs:
- name: Type Check (Svelte Check)
# Based on your package.json "check" script
run: pnpm check
- name: Run Tests (Vitest)
run: pnpm run test
+3 -1
View File
@@ -17,8 +17,10 @@ jobs:
http = true
insecure = true
- name: login
- name: login gitea registry
run: docker login -u schreifuchs -p ${{ secrets.REGISTRY_TOKEN }} git.schreifuchs.ch
- name: login dockerhub
run: docker login -u aktitiel -p ${{ secrets.DOCKER_HUB_TOKEN}}
- name: Build and push Docker image
uses: https://github.com/docker/build-push-action@v5
with:
+3 -1
View File
@@ -18,8 +18,10 @@ jobs:
http = true
insecure = true
- name: login
- name: login gitea registry
run: docker login -u schreifuchs -p ${{ secrets.REGISTRY_TOKEN }} git.schreifuchs.ch
- name: login dockerhub
run: docker login -u aktitiel -p ${{ secrets.DOCKER_HUB_TOKEN}}
- name: Build and push Docker image
uses: https://github.com/docker/build-push-action@v5
with:
+1
View File
@@ -1,2 +1,3 @@
pnpm run format
pnpm run lint
pnpm run test
+1 -1
View File
@@ -3,7 +3,7 @@ services:
image: postgres
restart: always
ports:
- 5433:5432
- 5432:5432
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: mysecretpassword
-1
View File
@@ -10,7 +10,6 @@ import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
{ ignores: ['src/lib/server/ecamp/clent/**'] },
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
+9 -5
View File
@@ -10,14 +10,15 @@
"prepare": "husky",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"db:start": "docker compose up",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"ecamp:generate": "openapi-ts -i src/lib/server/ecamp/openapi.yaml -o src/lib/server/ecamp/clent"
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@auth/core": "0.41.1",
@@ -25,13 +26,15 @@
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.1",
"@flowbite-svelte-plugins/texteditor": "^0.25.6",
"@hey-api/openapi-ts": "0.94.5",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@tiptap/core": "3.7.2",
"@types/node": "^20.19.25",
"@types/sanitize-html": "^2.16.1",
"drizzle-kit": "^0.31.7",
"drizzle-orm": "^0.44.7",
"eslint": "^9.39.1",
@@ -41,6 +44,7 @@
"flowbite-svelte-icons": "^3.0.0",
"globals": "^16.5.0",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
"lowlight": "^3.3.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
@@ -49,14 +53,14 @@
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.1.2"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.11.1",
"@sveltejs/adapter-auto": "^7.0.0",
"postgres": "^3.4.7",
"sanitize-html": "^2.17.0",
"set-cookie-parser": "^3.1.0",
"tailwind-merge": "^3.4.0",
"valibot": "^1.1.0"
}
+743 -331
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,3 +1,5 @@
import { DefaultSession } from '@auth/sveltekit';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
@@ -10,4 +12,13 @@ declare global {
}
}
declare module '@auth/sveltekit' {
interface Session {
user: {
id: string;
email: string;
} & DefaultSession['user'];
}
}
export {};
@@ -1,16 +0,0 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content-node';
import ContentNodeDisplay from '$lib/components/ecamp/ContentNodeDisplay.svelte';
let { node }: { node: ContentNode & { childNodes: any[] } } = $props();
</script>
<div class="border p-5">
<h3>{node.contentTypeName}</h3>
<p>{JSON.stringify(node)}</p>
{#each node.childNodes as child}
<ContentNodeDisplay node={child} />
{/each}
</div>
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { extractFormData } from './extractFormData';
import * as v from 'valibot';
describe('extractFormData', () => {
it('should successfully extract and validate correct form data', async () => {
const formData = new FormData();
formData.append('name', 'John Doe');
formData.append('age', '30');
const request = new Request('http://localhost', {
method: 'POST',
body: formData
});
const schema = v.object({
name: v.string(),
age: v.string()
});
const result = await extractFormData(request, schema);
expect(result.error).toBeNull();
expect(result.data).toEqual({ name: 'John Doe', age: '30' });
});
it('should fail validation with missing required fields', async () => {
const formData = new FormData();
formData.append('age', '30');
const request = new Request('http://localhost', {
method: 'POST',
body: formData
});
const schema = v.object({
name: v.string(),
age: v.string()
});
const result = await extractFormData(request, schema);
expect(result.data).toBeUndefined();
expect(result.error).toBeTypeOf('string');
});
});
-32
View File
@@ -1,32 +0,0 @@
import { form } from '$app/server';
import { ecamp } from '$lib/server/ecamp';
import { error } from '@sveltejs/kit';
import * as v from 'valibot';
export const importCamp = form(
v.object({
url: v.pipe(v.string(), v.nonEmpty(), v.url())
}),
async ({ url }) => {
// example import link = https://app.ecamp3.ch/camps/a0613ea6c551/Testlager/program/activity/f11670330910/e587f21c5881/Blachevolleyball
//
//// Extract the path segments into an array
const segments = url.split('/');
if (segments.length < 7) error(400, 'please use an url generated by ecamp');
// Based on the URL structure:
const campId = segments[2];
const activityId = segments[6];
await ecamp.ready;
await ecamp.acceptAllInvitaions();
}
);
async function ensureMembership(campId: string) {
if ((await ecamp.getCampIds()).find((id) => id === campId)) return true;
ecamp.acceptAllInvitaions();
}
+18
View File
@@ -0,0 +1,18 @@
import { db } from '$lib/server/db';
import { aktis, ratings } from '$lib/server/db/schema';
import { avg, eq } from 'drizzle-orm';
export async function getAktisWithAvgRating(limit = 20, offset = 0) {
return await db
.select({
id: aktis.id,
title: aktis.title,
summary: aktis.summary,
rating: avg(ratings.rating)
})
.from(aktis)
.leftJoin(ratings, eq(aktis.id, ratings.aktiId))
.groupBy(aktis.id, aktis.title, aktis.summary)
.limit(limit)
.offset(offset);
}
-2
View File
@@ -12,8 +12,6 @@ export const aktis = pgTable('akti', {
author: text('user_id')
.notNull()
.references(() => users.id),
originalLink: text(),
shareLink: text(),
version: integer('version').notNull().default(1)
});
-18
View File
@@ -1,18 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from './client';
import type { ClientOptions as ClientOptions2 } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
override?: Config<ClientOptions & T>
) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>());
@@ -1,290 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams
} from './utils.gen';
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const beforeRequest = async (options: RequestOptions) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined as string | undefined
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === '') {
opts.headers.delete('Content-Type');
}
const url = buildUrl(opts);
return { opts, url };
};
const request: Client['request'] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts)
};
let request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response: Response;
try {
response = await _fetch(request);
} catch (error) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
}
}
finalError = finalError || ({} as unknown);
if (opts.throwOnError) {
throw finalError;
}
// Return error response
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
request,
response: undefined as any
};
}
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response
};
if (response.ok) {
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
let emptyData: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result
};
}
let data: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'text':
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result
};
}
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data'
? data
: {
data,
...result
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
}
}
finalError = finalError || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
...result
};
};
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url
});
};
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
return {
buildUrl: _buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
sse: {
connect: makeSseFn('CONNECT'),
delete: makeSseFn('DELETE'),
get: makeSseFn('GET'),
head: makeSseFn('HEAD'),
options: makeSseFn('OPTIONS'),
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'),
put: makeSseFn('PUT'),
trace: makeSseFn('TRACE')
},
trace: makeMethodFn('TRACE')
} as Client;
};
@@ -1,25 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export { createClient } from './client.gen';
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';
@@ -1,210 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen';
import type { ServerSentEventsOptions, ServerSentEventsResult } from '../core/serverSentEvents.gen';
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
import type { Middleware } from './utils.gen';
export type ResponseStyle = 'data' | 'fields';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T['baseUrl'];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string
> extends Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Pick<
ServerSentEventsOptions<TData>,
| 'onRequest'
| 'onSseError'
| 'onSseEvent'
| 'sseDefaultRetryDelay'
| 'sseMaxRetryAttempts'
| 'sseMaxRetryDelay'
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields'
> = ThrowOnError extends true
? Promise<
TResponseStyle extends 'data'
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends 'data'
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
: (
| {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
}
) & {
request: Request;
response: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields'
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields'
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields'
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
}
>(
options: TData & Options<TData>
) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = 'fields'
> = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
@@ -1,316 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'form',
value,
...options.array
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...options.object
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join('&');
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return 'stream';
}
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) {
return;
}
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
return 'json';
}
if (cleanContent === 'multipart/form-data') {
return 'formData';
}
if (
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
) {
return 'blob';
}
if (cleanContent.startsWith('text/')) {
return 'text';
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
name?: string
): boolean => {
if (!name) {
return false;
}
if (
options.headers.has(name) ||
options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`)
) {
return true;
}
return false;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
default:
options.headers.set(name, token);
break;
}
}
};
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url
});
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header) {
continue;
}
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e., their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string)
);
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options
) => Res | Promise<Res>;
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = [];
clear(): void {
this.fns = [];
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn: Interceptor): number {
this.fns.push(fn);
return this.fns.length - 1;
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req,
Res,
Err,
Options
> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>()
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form'
},
object: {
explode: true,
style: 'deepObject'
}
});
const defaultHeaders = {
'Content-Type': 'application/json'
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {}
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override
});
@@ -1,41 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken
): Promise<string | undefined> => {
const token = typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}
return token;
};
@@ -1,82 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: unknown) => unknown;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>;
};
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: (body: unknown): FormData => {
const data = new FormData();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
}
};
export const jsonBodySerializer = {
bodySerializer: (body: unknown): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value))
};
export const urlSearchParamsBodySerializer = {
bodySerializer: (body: unknown): string => {
const data = new URLSearchParams();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
}
};
@@ -1,169 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query'
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
| {
in: Slot;
map?: string;
}
| {
in?: never;
map: Slot;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map
});
}
} else if ('key' in config) {
map.set(config.key, {
map: config.map
});
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {}
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
if (field.in) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
} else if ('allowExtra' in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};
@@ -1,171 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string
});
})
.join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.'
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
});
const joinedValues = values.join(',');
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string
})
)
.join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
};
@@ -1,117 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue =
| null
| string
| number
| boolean
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
if (value === null) {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};
@@ -1,243 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen';
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit['body'];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
stream: AsyncGenerator<
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
TReturn,
TNext
>;
};
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set('Last-Event-ID', lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
signal
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener('abort', abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
// Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const lines = chunk.split('\n');
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.replace(/^data:\s*/, ''));
} else if (line.startsWith('event:')) {
eventName = line.replace(/^event:\s*/, '');
} else if (line.startsWith('id:')) {
lastEventId = line.replace(/^id:\s*/, '');
} else if (line.startsWith('retry:')) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
};
@@ -1,104 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen';
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
export type HttpMethod =
| 'connect'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'patch'
| 'post'
| 'put'
| 'trace';
export type Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
SseFn = never
> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
getConfig: () => Config;
request: RequestFn;
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>;
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g., converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
};
@@ -1,140 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam
} from './pathSerializer.gen';
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true
})
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string
})}`
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string)
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ('serializedBody' in options) {
const hasSerializedBody =
options.serializedBody !== undefined && options.serializedBody !== '';
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e., client-axios)
return options.body !== '' ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-288
View File
@@ -1,288 +0,0 @@
import { type ContentNode } from '$lib/types/content-node.d';
import { parseSetCookie } from 'set-cookie-parser';
/**
* eCamp3 API Client
*
* Supports authentication and retrieval of activities with their content nodes.
*/
export interface AuthResponse {
token: string;
}
export interface Activity {
'@id': string;
'@type': string;
id: string;
title: string;
location: string;
camp: string; // IRI
category: Category | string; // Embedded or IRI
progressLabel: ActivityProgressLabel | string | null; // Embedded or IRI
scheduleEntries: ScheduleEntry[];
contentNodes?: ContentNode[]; // Populated when using specific normalization groups
rootContentNode: string; // IRI to the root ColumnLayout
createTime: string;
updateTime: string;
}
export interface Category {
'@id': string;
id: string;
name: string;
color: string;
numbering: string;
}
export interface ActivityProgressLabel {
'@id': string;
id: string;
name: string;
color: string;
}
export interface ScheduleEntry {
'@id': string;
id: string;
period: string; // IRI
start: string; // ISO Date
end: string; // ISO Date
}
export interface PaginatedResponse<T> {
'hydra:member': T[];
'hydra:totalItems': number;
'hydra:view'?: {
'@id': string;
'@type': string;
'hydra:first': string;
'hydra:last': string;
'hydra:next'?: string;
};
}
interface Invitation {
id: string;
campId: string;
campTitle: string;
}
export interface Options {
auth?: AuthOptions;
}
export interface AuthOptions {
username: string;
password: string;
}
interface Auth {
content?: string;
signature?: string;
refresh?: string;
}
interface Link {
href: string;
}
interface GetCampsResponse {
_links: {
items: Link[];
};
}
export class ECampClient {
private auth: Auth | null = null;
private baseUrl: string;
private _ready: Promise<void>;
public get ready() {
return this._ready;
}
constructor(baseUrl: string = 'http://localhost:3000/api', options?: Options) {
// Remove trailing slash if present
this.baseUrl = baseUrl.replace(/\/$/, '');
if (options?.auth) {
this._ready = this.login(options.auth.username, options.auth.password).catch((e) => {
console.error(e);
});
} else {
this._ready = new Promise<void>((r) => r());
}
}
/**
* Authenticate with the API using email and password.
*/
async login(email: string, password: string): Promise<void> {
console.debug('start login');
const response = await fetch(`${this.baseUrl}/authentication_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ identifier: email, password })
});
if (!response.ok) {
throw new Error(`Login failed: ${response.statusText}`);
}
const cookies = parseSetCookie(response.headers.getSetCookie(), { decodeValues: true });
cookies.forEach((c) => {
if (c.name.endsWith('jwt_hp')) {
this.auth = {
...this.auth,
content: c.value
};
return;
}
if (c.name.endsWith('jwt_s')) {
this.auth = {
...this.auth,
signature: c.value
};
return;
}
if (c.name.endsWith('refresh_token')) {
this.auth = {
...this.auth,
refresh: c.value
};
return;
}
});
console.debug('loggin succeded');
}
/**
* Set a pre-existing JWT token.
*/
/**
* Get a list of activities.
* If campId is provided, filters by that camp.
*/
async getActivities(campId?: string): Promise<Activity[]> {
let url = `${this.baseUrl}/activities`;
if (campId) {
// Handle both raw ID and IRI
const id = campId.includes('/') ? campId.split('/').pop() : campId;
url += `?camp=${encodeURIComponent(`/camps/${id}`)}`;
}
const data = await this.fetchJson<PaginatedResponse<Activity>>(url);
return data['hydra:member'];
}
/**
* Get a single activity by its ID or IRI.
* This includes the embedded content nodes.
*/
async getActivity(activityId: string): Promise<Activity> {
const id = activityId.includes('/') ? activityId.split('/').pop() : activityId;
const url = `${this.baseUrl}/activities/${id}`;
return this.fetchJson<Activity>(url);
}
private async getInvitations() {
const url = `${this.baseUrl}/personal_invitations`;
return this.fetchJson<Invitation[]>(url);
}
private async acceptInvitation(invitation: Invitation) {
const url = `${this.baseUrl}/personal_invitations/${invitation.id}/accept`;
return this.fetchJson<Invitation>(url, {
method: 'PATCH',
body: '{}',
headers: { 'Content-Type': 'application/merge-patch+json' }
});
}
async getCampIds(): Promise<string[]> {
const url = `${this.baseUrl}/camps`;
const res = await this.fetchJson<GetCampsResponse>(url);
return res._links.items.map((l) => l.href.split('/').pop()).filter((e) => e !== undefined);
}
async acceptAllInvitaions() {
const invitations = await this.getInvitations();
if (invitations.length <= 0) return;
await Promise.all(
invitations.map((i) =>
this.acceptInvitation(i).catch((e) => console.error('could not accept invitation', i, e))
)
);
}
async getActivityContent(activityId: string) {
const activity = await this.getActivity(activityId);
const contents = await this.getContentNodes(activity.rootContentNode);
return this.reconstructTree(contents);
}
/**
* Get all content nodes belonging to a specific root node (tree).
*/
async getContentNodes(rootIri: string): Promise<ContentNode[]> {
// The API allows filtering content nodes by their root node
const url = `${this.baseUrl}/content_nodes?root=${encodeURIComponent(rootIri)}`;
return this.fetchJson<ContentNode[]>(url);
}
/**
* Reconstructs a tree structure from a flat list of ContentNodes.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reconstructTree(nodes: ContentNode[]): (ContentNode & { childNodes: any[] })[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeMap = new Map<string, ContentNode & { childNodes: any[] }>();
// First pass: Create the map and initialize children arrays
nodes.forEach((node) => {
nodeMap.set(node.id!, { ...node, childNodes: [] });
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rootNodes: (ContentNode & { childNodes: any[] })[] = [];
// Second pass: Build the tree
nodes.forEach((node) => {
const wrappedNode = nodeMap.get(node.id!)!;
if (node.parent && nodeMap.has(node.parent)) {
const parent = nodeMap.get(node.parent)!;
parent.childNodes.push(wrappedNode);
// Sort by position
parent.childNodes.sort((a, b) => a.position - b.position);
} else {
rootNodes.push(wrappedNode);
}
});
return rootNodes;
}
private async fetchJson<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
...(options.headers as Record<string, string>)
};
if (this.auth?.content && this.auth.refresh) {
headers['Authorization'] = `Bearer ${this.auth.content}.${this.auth.signature}`;
}
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
if (response.status === 401) {
throw new Error('Unauthorized: Please check your credentials or token.');
}
throw new Error(`API Request failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
}
-6
View File
@@ -1,6 +0,0 @@
import { env } from '$env/dynamic/private';
import { ECampClient } from './client';
export const ecamp: ECampClient = new ECampClient(env.ECAMP_BASE_URL, {
auth: { username: env.ECAMP_USER, password: env.ECAMP_PASSWORD }
});
File diff suppressed because it is too large Load Diff
+8 -11
View File
@@ -1,39 +1,36 @@
import type { Session, User } from '@auth/sveltekit';
import type { Session } from '@auth/sveltekit';
import { error } from '@sveltejs/kit';
import { db } from './server/db';
import { users } from './server/db/schema';
import { db } from './db';
import { users } from './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> {
export async function ensureAuth(event: Event): Promise<Session['user']> {
const session = await getSession(event);
if (!session) error(401, { message: 'Du muesch di zersch iiloge' });
const user = session?.user;
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
return user;
}
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?' });
if (!session?.user?.email) return null;
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
.where(eq(users.email, session.user.email));
if (!res[0]?.id) {
error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' });
-150
View File
@@ -1,150 +0,0 @@
/**
* eCamp3 Content Node Types
* Generated based on PHP Entity definitions.
*/
/** Base properties shared by all ContentNodes */
export interface ContentNode {
/** JSON-LD context */
'@id'?: string;
/** JSON-LD type */
'@type'?: string;
/** Internal unique identifier (12-character hex string) */
id: string;
/** IRI of the root content node in the tree (usually a ColumnLayout) */
root: string;
/** IRI of the parent node. Null if this is the root. */
parent: string | null;
/** Array of IRIs pointing to direct child content nodes */
children: string[];
/** Name of the slot in the parent container where this node resides */
slot: string | null;
/** Ordering position within the current slot */
position: number;
/** Optional display name (e.g., "Bad Weather Program") */
instanceName: string | null;
/** IRI of the associated ContentType */
contentType: string;
/** Convenience field for the name of the content type */
contentTypeName: string;
/** ISO DateTime string representing creation time */
createTime?: string;
/** ISO DateTime string representing last update time */
updateTime?: string;
}
/** 1. Single Text (Standard free-text field) */
export interface SingleTextNode extends ContentNode {
contentTypeName: 'SingleText';
data: {
/** HTML content of the text field */
html: string;
};
}
/** 2. Storyboard (Three-column activity planning grid) */
export interface StoryboardNode extends ContentNode {
contentTypeName: 'Storyboard';
data: {
/** Keyed by UUID v4 */
sections: {
[uuid: string]: {
column1: string;
column2Html: string;
column3: string;
position: number;
};
};
};
}
/** 3. MultiSelect (Checkbox options, e.g., for safety or environment topics) */
export interface MultiSelectNode extends ContentNode {
contentTypeName: 'MultiSelect';
data: {
options: {
[key: string]: {
checked: boolean;
};
};
};
}
/** 4. Column Layout (Container used to split content into columns) */
export interface ColumnLayoutNode extends ContentNode {
contentTypeName: 'ColumnLayout';
data: {
columns: Array<{
/** Slot identifier (numeric string) */
slot: string;
/** Column width in a 12-column grid */
width: number;
}>;
};
}
/** 5. Responsive Layout (Specialized container for Main/Sidebar layouts) */
export interface ResponsiveLayoutNode extends ContentNode {
contentTypeName: 'ResponsiveLayout';
data: {
items: Array<{
slot: 'main' | 'aside-top' | 'aside-bottom';
}>;
};
}
/** 6. Material Node (Container for physical equipment items) */
export interface MaterialNode extends ContentNode {
contentTypeName: 'MaterialNode';
/** Material nodes typically have null data as they use a OneToMany relation */
data: null;
/** Embedded MaterialItems for this node type */
materialItems: MaterialItem[];
}
/** A physical item required for a programme */
export interface MaterialItem {
'@id'?: string;
id: string;
/** Name/description of the item */
article: string;
/** Quantity required */
quantity: number | null;
/** Unit of measurement (e.g., 'kg', 'pieces') */
unit: string | null;
/** Preparation status */
done: boolean;
/** IRI of the MaterialList this belongs to */
materialList: string;
/** IRI of the Camp this belongs to */
camp: string;
}
/** 7. Checklist Node (Selection of tasks/checklist items) */
export interface ChecklistNode extends ContentNode {
contentTypeName: 'ChecklistNode';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: null | Record<string, any>;
/** IRIs to ChecklistItem entities */
checklistItems: string[];
}
/** Discriminated Union for safe handling of ContentNodes in TypeScript */
export type ContentNode =
| SingleTextNode
| StoryboardNode
| MultiSelectNode
| ColumnLayoutNode
| ResponsiveLayoutNode
| MaterialNode
| ChecklistNode;
+1 -1
View File
@@ -1,4 +1,4 @@
import { getSession as getSession } from '$lib/auth';
import { getSession } from '$lib/server/session';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
+6 -14
View File
@@ -1,19 +1,11 @@
import { db } from '$lib/server/db';
import { aktis, ratings } from '$lib/server/db/schema';
import { avg, eq } from 'drizzle-orm';
import { getAktisWithAvgRating } from '$lib/server/db/queries';
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);
export const load: PageServerLoad = async ({ url }) => {
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = 20;
const a = await getAktisWithAvgRating(limit, offset);
return {
aktis: a.map((a) => ({ ...a, rating: a.rating ? parseFloat(a.rating) : undefined }))
+44 -1
View File
@@ -1,12 +1,55 @@
<script lang="ts">
import AktiCard from '$lib/components/akti/AktiCard.svelte';
import type { PageProps } from './$types';
import { Spinner } from 'flowbite-svelte';
let { data }: PageProps = $props();
let aktis = $state(data.aktis);
let offset = $state(data.aktis.length);
let loading = $state(false);
let hasMore = $state(data.aktis.length >= 20);
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
const res = await fetch(`/api/aktis?offset=${offset}`);
const newAktis = await res.json();
if (newAktis.length < 20) {
hasMore = false;
}
aktis = [...aktis, ...newAktis];
offset += newAktis.length;
loading = false;
}
function infiniteScroll(node: HTMLElement) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
});
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}
</script>
<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)}
{#each aktis as akti (akti.id)}
<AktiCard {akti}></AktiCard>
{/each}
</div>
<div class="mt-10 flex justify-center h-20">
{#if loading}
<Spinner />
{:else if hasMore}
<div use:infiniteScroll></div>
{/if}
</div>
+4 -1
View File
@@ -4,9 +4,10 @@ import { extractFormData } from '$lib/extractFormData';
import { resolve } from '$app/paths';
import * as v from 'valibot';
import { ensureAuth } from '$lib/auth';
import { ensureAuth } from '$lib/server/session';
import { db } from '$lib/server/db';
import { aktis } from '$lib/server/db/schema';
import sanitizeHtml from 'sanitize-html';
export const load: PageServerLoad = async (event) => {
await ensureAuth(event);
return {};
@@ -28,6 +29,8 @@ export const actions = {
if (!akti) return {};
akti.body = sanitizeHtml(akti.body);
const res = await db
.insert(aktis)
.values({ ...akti, author: user.id! })
+12 -8
View File
@@ -1,23 +1,25 @@
import { db } from '$lib/server/db';
import { aktis, ratings } from '$lib/server/db/schema';
import { error, redirect, type Actions } from '@sveltejs/kit';
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { ensureAuth } from '$lib/auth';
import { ensureAuth } from '$lib/server/session';
import { extractFormData } from '$lib/extractFormData';
import * as v from 'valibot';
import { resolve } from '$app/paths';
import sanitizeHtml from 'sanitize-html';
export const load: PageServerLoad = async (event) => {
const akti = await db.query.aktis.findFirst({
const [akti, r] = await Promise.all([
db.query.aktis.findFirst({
where: eq(aktis.id, event.params.aktiId),
with: { author: true }
});
const r = await db.query.ratings.findMany({
}),
db.query.ratings.findMany({
with: { user: true },
where: eq(ratings.aktiId, event.params.aktiId)
});
})
]);
if (!akti) {
error(404, { message: 'Die Akti gits garnid, sorry...' });
@@ -54,7 +56,9 @@ export const actions = {
)
).data;
if (!changeRequest) return error(400);
if (!changeRequest) return fail(400, { message: 'Invalid data' });
changeRequest.body = sanitizeHtml(changeRequest.body);
await db
.update(aktis)
@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types';
import { ensureAuth } from '$lib/auth';
import { error, redirect, type Actions } from '@sveltejs/kit';
import { ensureAuth } from '$lib/server/session';
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { extractFormData } from '$lib/extractFormData';
import { aktis, ratings } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
@@ -54,7 +54,7 @@ export const actions = {
)
).data;
if (!rating) return error(400);
if (!rating) return fail(400, { message: 'Invalid data' });
await db.insert(ratings).values({
...rating,
+11
View File
@@ -0,0 +1,11 @@
import { getAktisWithAvgRating } from '$lib/server/db/queries';
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ url }) => {
const offset = Number(url.searchParams.get('offset')) || 0;
const limit = 20;
const a = await getAktisWithAvgRating(limit, offset);
return json(a.map((a) => ({ ...a, rating: a.rating ? parseFloat(a.rating) : undefined })));
};
-9
View File
@@ -1,9 +0,0 @@
<script lang="ts">
import { Button } from 'flowbite-svelte';
</script>
<Button
onclick={() => {
navigator.clipboard.read().then((i) => console.log(i));
}}>Import</Button
>
@@ -1,12 +0,0 @@
import type { PageServerLoad } from './$types';
import { ensureAuth } from '$lib/auth';
import { ecamp } from '$lib/server/ecamp';
export const load: PageServerLoad = async (event) => {
await ensureAuth(event);
await ecamp.ready;
await ecamp.acceptAllInvitaions();
const activity = await ecamp.getActivityContent(event.params.activityId);
return { activity };
};
@@ -1,85 +0,0 @@
<script lang="ts">
import ContentNodeDisplay from '$lib/components/ecamp/ContentNodeDisplay.svelte';
import type { MaterialNode, SingleTextNode, StoryboardNode } from '$lib/types/content-node';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let storyboard = $derived.by(() => {
const storyboard = data.activity.find((n) => n.contentTypeName === 'Storyboard');
if (!storyboard) return [];
const sections = (storyboard as object as StoryboardNode).data.sections;
return Object.values(sections).sort((a, b) => a.position - b.position);
});
let safety = $derived.by(() => {
const safety = data.activity.find((n) => n.contentTypeName === 'SafetyConsiderations');
if (!safety) return null;
return (safety as object as SingleTextNode).data.html;
});
let materials = $derived.by(() => {
const materials = data.activity.find((n) => n.contentTypeName === 'Material');
if (!materials) return [];
const items = (materials as object as MaterialNode).materialItems;
return items;
});
</script>
{#if storyboard.length > 0}
<h3>storyboard</h3>
<table>
<thead>
<tr>
<th>Zyt</th>
<th>Programm</th>
<th>Zueständig</th>
</tr>
</thead>
<tbody>
{#each storyboard as entry (entry.position)}
<tr>
<td>{entry.column1}</td>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<td>{@html entry.column2Html}</td>
<td>{entry.column3}</td>
</tr>{/each}
</tbody>
</table>
{/if}
{#if safety}
<section>
<h3>safety considerations</h3>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html safety}
</section>
{/if}
{#if materials.length > 0}
<h3>materials</h3>
<table>
<thead>
<tr>
<th>Mängi</th>
<th>Iheit</th>
<th>Materiau</th>
</tr>
</thead>
<tbody>
{#each materials as item (item.id)}
<tr>
<td>{item.quantity}</td>
<td>{item.unit}</td>
<td>{item.article}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#each data.activity as node}
<ContentNodeDisplay {node} />
{/each}
+1 -10
View File
@@ -11,16 +11,7 @@ const config = {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
experimental: {
remoteFunctions: true
}
},
compilerOptions: {
experimental: {
async: true
}
adapter: adapter()
}
};
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
export default defineConfig({
plugins: [sveltekit(), svelteTesting()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
setupFiles: ['./vitest-setup.ts']
}
});