feat: Reinitialize frontend with SvelteKit and TypeScript

- Delete old Vite+Svelte frontend
- Initialize new SvelteKit project with TypeScript
- Configure Tailwind CSS v4 + DaisyUI
- Implement JWT authentication with auto-refresh
- Create login page with form validation (Zod)
- Add protected route guards
- Update Docker configuration for single-stage build
- Add E2E tests with Playwright (6/11 passing)
- Fix Svelte 5 reactivity with $state() runes

Known issues:
- 5 E2E tests failing (timing/async issues)
- Token refresh implementation needs debugging
- Validation error display timing
This commit is contained in:
2026-02-17 16:19:59 -05:00
parent 54df6018f5
commit de2d83092e
28274 changed files with 3816354 additions and 90 deletions

View File

@@ -0,0 +1,3 @@
import type { Converter } from '../types.js';
declare const arrayConverter: Converter;
export default arrayConverter;

View File

@@ -0,0 +1,31 @@
import commonConverter from './common.js';
const arrayConverter = (description, converters) => {
const jsonSchema = commonConverter(description, converters);
const meta = description.meta || {};
const { innerType } = description;
if (innerType) {
const converter = converters[innerType.type];
jsonSchema.items = converter(innerType, converters);
}
description.tests.forEach((test) => {
switch (test.name) {
case 'length':
if (test.params?.length !== undefined) {
jsonSchema.minItems = jsonSchema.maxItems = Number(test.params.length);
}
break;
case 'min':
if (test.params?.min !== undefined) {
jsonSchema.minItems = Number(test.params.min);
}
break;
case 'max':
if (test.params?.max !== undefined) {
jsonSchema.maxItems = Number(test.params.max);
}
break;
}
});
return Object.assign(jsonSchema, meta.jsonSchema);
};
export default arrayConverter;

View File

@@ -0,0 +1,3 @@
import type { Converter } from '../types.js';
declare const booleanConverter: Converter;
export default booleanConverter;

View File

@@ -0,0 +1,7 @@
import commonConverter from './common.js';
const booleanConverter = (description, converters) => {
const jsonSchema = commonConverter(description, converters);
const meta = description.meta || {};
return Object.assign(jsonSchema, meta.jsonSchema);
};
export default booleanConverter;

View File

@@ -0,0 +1,3 @@
import type { Converter } from '../types.js';
declare const commonConverter: Converter;
export default commonConverter;

View File

@@ -0,0 +1,25 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const commonConverter = (description, converters) => {
const jsonSchema = {};
jsonSchema.type = description.type;
if (description.nullable) {
jsonSchema.type = [jsonSchema.type, 'null'];
}
if (description.oneOf?.length > 0) {
jsonSchema.enum = description.oneOf;
}
if (description.notOneOf?.length > 0) {
jsonSchema.not = {
enum: description.notOneOf
};
}
if (description.label) {
jsonSchema.title = description.label;
}
if (description.default !== undefined) {
// @ts-expect-error default is unknown
jsonSchema.default = description.default;
}
return jsonSchema;
};
export default commonConverter;

View File

@@ -0,0 +1,3 @@
import type { Converter } from '../types.js';
declare const dateConverter: Converter;
export default dateConverter;

View File

@@ -0,0 +1,9 @@
import commonConverter from './common.js';
const dateConverter = (description, converters) => {
const jsonSchema = commonConverter(description, converters);
const meta = description.meta || {};
jsonSchema.type = 'string';
jsonSchema.format = 'date-time';
return Object.assign(jsonSchema, meta.jsonSchema);
};
export default dateConverter;

View File

@@ -0,0 +1,4 @@
import type { AnySchema } from 'yup';
import type { JSONSchema7 } from 'json-schema';
import type { ResolveOptions } from '../types.js';
export declare function convertSchema(yupSchema: AnySchema, options?: ResolveOptions): JSONSchema7;

View File

@@ -0,0 +1,27 @@
import stringConverter from './string.js';
import numberConverter from './number.js';
import booleanConverter from './boolean.js';
import dateConverter from './date.js';
import arrayConverter from './array.js';
import objectConverter from './object.js';
import tupleConverter from './tuple.js';
import mixedConverter from './mixed.js';
import lazyConverter from './lazy.js';
export function convertSchema(yupSchema, options) {
const { converters, ...resolveOptions } = options || {};
const allConverters = {
string: stringConverter,
number: numberConverter,
boolean: booleanConverter,
date: dateConverter,
array: arrayConverter,
object: objectConverter,
tuple: tupleConverter,
mixed: mixedConverter,
lazy: lazyConverter,
...converters
};
const description = yupSchema.describe(resolveOptions);
const converter = allConverters[description.type];
return converter(description, allConverters);
}

View File

@@ -0,0 +1,3 @@
import type { Converter } from '../types.js';
declare const lazyConverter: Converter;
export default lazyConverter;

View File

@@ -0,0 +1,8 @@
import commonConverter from './common.js';
/* lazy is kind on an intermediate type. If you call schema.describe() with any argument, even schema.describe({}) which this library does by default, then the lazy functions always try to resolve to their return types. Because we always call schema.describe({}) or schema.describe(ResolveOptions) this is mostly unused but should still be here and return an empty type if it does exist in the schema description for some reason */
const lazyConverter = (description, converters) => {
const jsonSchema = commonConverter(description, converters);
const meta = description.meta || {};
return Object.assign(jsonSchema, meta.jsonSchema);
};
export default lazyConverter;

View File

@@ -0,0 +1,3 @@
import type { Converter } from '../types.js';
declare const mixedConverter: Converter;
export default mixedConverter;

View File

@@ -0,0 +1,45 @@
import commonConverter from './common.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getType = (item) => {
switch (typeof item) {
case 'string':
return 'string';
case 'number':
return 'number';
case 'boolean':
return 'boolean';
case 'object':
if (Array.isArray(item)) {
return 'array';
}
else if (item === null) {
return 'null';
}
else if (item instanceof Date) {
return 'string';
}
else {
return 'object';
}
default:
return 'null';
}
};
const mixedConverter = (description, converters) => {
const jsonSchema = commonConverter(description, converters);
const meta = description.meta || {};
let types = Array.isArray(description.type) ? description.type : [description.type];
types = types.filter((type) => type !== 'mixed');
if (description.oneOf?.length > 0) {
description.oneOf.forEach((item) => {
types.push(getType(item));
});
}
if (description.default !== undefined) {
types.push(getType(description.default));
}
types = types.filter((type, index, self) => self.indexOf(type) === index);
jsonSchema.type = types;
return Object.assign(jsonSchema, meta.jsonSchema);
};
export default mixedConverter;

View File

@@ -0,0 +1,3 @@
import type { Converter } from '../types.js';
declare const numberConverter: Converter;
export default numberConverter;

View File

@@ -0,0 +1,35 @@
import commonConverter from './common.js';
const numberConverter = (description, converters) => {
const jsonSchema = commonConverter(description, converters);
const meta = description.meta || {};
description.tests.forEach((test) => {
switch (test.name) {
case 'min':
if (test.params?.min !== undefined) {
jsonSchema.minimum = Number(test.params.min);
}
if (test.params?.more !== undefined) {
jsonSchema.exclusiveMinimum = Number(test.params.more);
}
break;
case 'max':
if (test.params?.max !== undefined) {
jsonSchema.maximum = Number(test.params.max);
}
if (test.params?.less !== undefined) {
jsonSchema.exclusiveMaximum = Number(test.params.less);
}
break;
case 'integer':
if (jsonSchema.type === 'number') {
jsonSchema.type = 'integer';
}
else {
// @ts-expect-error type is known
jsonSchema.type = [...jsonSchema.type, 'integer'].filter((type) => type !== 'number');
}
}
});
return Object.assign(jsonSchema, meta.jsonSchema);
};
export default numberConverter;

View File

@@ -0,0 +1,3 @@
import type { Converter } from '../types.js';
declare const objectConverter: Converter;
export default objectConverter;

View File

@@ -0,0 +1,25 @@
import commonConverter from './common.js';
// @ts-expect-error description is known
const objectConverter = (description, converters) => {
/* Yup automatically adds an object where each key is undefined as the deafault in its description. So objects automatically get a default :(. The developer should use jsonSchema({ default: undefined }) to remedy this */
const jsonSchema = commonConverter(description, converters);
const meta = description.meta || {};
const properties = {};
const required = [];
Object.keys(description.fields).forEach((fieldName) => {
const fieldDescription = description.fields[fieldName];
const converter = converters[fieldDescription.type];
properties[fieldName] = converter(fieldDescription, converters);
if (!fieldDescription.optional) {
required.push(fieldName);
}
});
if (Object.keys(properties).length > 0) {
jsonSchema.properties = properties;
}
if (Object.keys(required).length > 0) {
jsonSchema.required = required;
}
return Object.assign(jsonSchema, meta.jsonSchema);
};
export default objectConverter;

View File

@@ -0,0 +1,4 @@
import type { Converter } from '../types.js';
export declare const uuidRegExPattern = "^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$";
declare const stringConverter: Converter;
export default stringConverter;

View File

@@ -0,0 +1,45 @@
import commonConverter from './common.js';
export const uuidRegExPattern = '^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$';
const stringConverter = (description, converters) => {
const jsonSchema = commonConverter(description, converters);
const meta = description.meta || {};
description.tests.forEach((test) => {
switch (test.name) {
case 'length':
if (test.params?.length !== undefined) {
jsonSchema.minLength = Number(test.params.length);
jsonSchema.maxLength = Number(test.params.length);
}
break;
case 'min':
if (test.params?.min !== undefined) {
jsonSchema.minLength = Number(test.params.min);
}
break;
case 'max':
if (test.params?.max !== undefined) {
jsonSchema.maxLength = Number(test.params.max);
}
break;
case 'matches':
if (test.params?.regex) {
jsonSchema.pattern = test.params.regex
.toString()
.replace(/^\/(.*)\/[gimusy]*$/, '$1');
}
break;
case 'email':
jsonSchema.format = 'email';
break;
case 'url':
jsonSchema.format = 'uri';
break;
case 'uuid':
jsonSchema.format = 'uuid';
jsonSchema.pattern = uuidRegExPattern;
break;
}
});
return Object.assign(jsonSchema, meta.jsonSchema);
};
export default stringConverter;

View File

@@ -0,0 +1,3 @@
import type { Converter } from '../types.js';
declare const tupleConverter: Converter;
export default tupleConverter;

View File

@@ -0,0 +1,15 @@
import commonConverter from './common.js';
// @ts-expect-error description is known
const tupleConverter = (description, converters) => {
const jsonSchema = commonConverter(description, converters);
const meta = description.meta || {};
jsonSchema.type = 'array';
jsonSchema.items = description.innerType.map((description) => {
const converter = converters[description.type];
return converter(description, converters);
});
jsonSchema.minItems = jsonSchema.items.length;
jsonSchema.maxItems = jsonSchema.items.length;
return Object.assign(jsonSchema, meta.jsonSchema);
};
export default tupleConverter;

View File

@@ -0,0 +1,2 @@
export { convertSchema } from './converters/index.js';
export { extendSchema } from './methods/index.js';

View File

@@ -0,0 +1,2 @@
export { convertSchema } from './converters/index.js';
export { extendSchema } from './methods/index.js';

View File

@@ -0,0 +1,6 @@
type YupParams = {
addMethod: any;
Schema: any;
};
export declare function extendSchema(yup: YupParams): void;
export {};

View File

@@ -0,0 +1,24 @@
function addMethod(yup, name) {
yup.addMethod(yup.Schema, name, function (value) {
const meta = this.describe().meta || {};
return this.meta({
...meta,
jsonSchema: {
...meta.jsonSchema,
[name]: value
}
});
});
}
export function extendSchema(yup) {
addMethod(yup, 'example');
addMethod(yup, 'examples');
addMethod(yup, 'description');
yup.addMethod(yup.Schema, 'jsonSchema', function (callback) {
const meta = this.describe().meta || {};
return this.meta({
...meta,
jsonSchema: callback(meta.jsonSchema || {})
});
});
}

View File

@@ -0,0 +1,16 @@
import type { JSONSchema7 } from 'json-schema';
import type { SchemaDescription } from 'yup';
export type YupType = 'array' | 'boolean' | 'date' | 'lazy' | 'mixed' | 'number' | 'object' | 'string' | 'tuple';
export type Converters = Record<YupType, Converter>;
export type Converter = (description: SchemaDescription, converters: Converters) => JSONSchema7;
export type Meta = {
jsonSchema?: JSONSchema7;
[key: string]: any;
};
export type ResolveOptions = {
value?: unknown;
parent?: unknown;
context?: unknown;
converters?: Partial<Converters>;
};
export type JsonSchemaCallback = (jsonSchema: JSONSchema7) => JSONSchema7;

View File

@@ -0,0 +1 @@
export {};