feat: initial frontend auth handlers & theming

This commit is contained in:
ae 2025-04-14 14:21:07 +03:00
parent 0e2c40b5ca
commit b1c7fe165e
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
28 changed files with 3439 additions and 0 deletions

23
web/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
web/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

6
web/.prettierignore Normal file
View File

@ -0,0 +1,6 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

16
web/.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": false,
"semi": false,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

15
web/nginx.conf Normal file
View File

@ -0,0 +1,15 @@
server {
listen 80; # TLS termination is handled by Traefik
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://notatest-server:8080; # Internal Docker DNS
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

2454
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
web/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "sveltetest",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.1",
"autoprefixer": "^10.4.21",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"dompurify": "^3.2.5",
"marked": "^15.0.7"
}
}

166
web/src/app.css Normal file
View File

@ -0,0 +1,166 @@
@import "tailwindcss";
:root {
--light-background: #f5f5f5;
--light-foreground: #e0e0e0;
--light-accent: #f3b421;
--light-text: #222831;
--dark-background: #222831;
--dark-foreground: #393e46;
--dark-accent: #ffd369;
--dark-text: #eeeeee;
}
@layer base {
body {
@apply bg-[var(--light-background)] text-[var(--light-text)] transition-colors duration-200;
}
.dark body {
@apply bg-[var(--dark-background)] text-[var(--dark-text)];
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-medium text-[var(--light-text)];
}
.dark h1,
.dark h2,
.dark h3,
.dark h4,
.dark h5,
.dark h6 {
@apply text-[var(--dark-text)];
}
a {
@apply text-[var(--light-accent)] transition-colors duration-200;
}
a:hover {
@apply underline;
}
.dark a {
@apply text-[var(--dark-accent)];
}
input,
textarea,
select {
@apply rounded-lg border border-[var(--light-text)]/20 bg-[var(--light-foreground)] px-3 py-2 text-[var(--light-text)] transition-colors duration-200;
}
input:focus,
textarea:focus,
select:focus {
@apply ring-2 ring-[var(--light-accent)] outline-none;
}
.dark input,
.dark textarea,
.dark select {
@apply border-[var(--dark-text)]/20 bg-[var(--dark-foreground)] text-[var(--dark-text)];
}
.dark input:focus,
.dark textarea:focus,
.dark select:focus {
@apply ring-[var(--dark-accent)];
}
button {
@apply rounded-md bg-[var(--light-accent)] px-4 py-2 text-[var(--light-background)] transition-all duration-200;
}
button:hover {
@apply bg-[var(--light-accent)]/80;
}
button:focus {
@apply ring-2 ring-[var(--light-accent)]/50 outline-none;
}
button:disabled {
@apply opacity-50;
}
.dark button {
@apply bg-[var(--dark-accent)] text-[var(--dark-background)];
}
.dark button:hover {
@apply bg-[var(--dark-accent)]/80;
}
.dark button:focus {
@apply ring-[var(--dark-accent)]/50;
}
}
/* Reusable component classes */
@layer components {
.card {
@apply rounded-lg bg-[var(--light-foreground)] p-6 shadow-md transition-colors duration-200;
}
.dark .card {
@apply bg-[var(--dark-foreground)];
}
.form-group {
@apply mb-4 space-y-2;
}
.form-label {
@apply block text-sm font-medium text-[var(--light-text)];
}
.dark .form-label {
@apply text-[var(--dark-text)];
}
/* Error messages */
.error {
@apply rounded-lg bg-red-100 p-3 text-sm text-red-500;
}
.dark .error {
@apply bg-red-900/30 text-red-300;
}
/* Success messages */
.success {
@apply rounded-lg bg-green-100 p-3 text-sm text-green-500;
}
.dark .success {
@apply bg-green-900/30 text-green-300;
}
.btn-primary {
@apply bg-[var(--light-accent)] text-[var(--light-background)];
}
.dark .btn-primary {
@apply bg-[var(--dark-accent)] text-[var(--dark-background)];
}
.btn-secondary {
@apply border border-[var(--light-text)]/20 bg-[var(--light-foreground)] text-[var(--light-text)];
}
.dark .btn-secondary {
@apply border-[var(--dark-text)]/20 bg-[var(--dark-foreground)] text-[var(--dark-text)];
}
.container-page {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
}

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

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

12
web/src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

365
web/src/lib/client.ts Normal file
View File

@ -0,0 +1,365 @@
import { derived, get, writable, type Writable } from "svelte/store"
import { API_BASE_ADDR, AT_EXP_MS, CSRF_EXP_MS, REFRESH_BUF, UUID_REGEX } from "./const"
import { goto } from "$app/navigation"
import { usersPagination } from "./pages"
interface UserResponse {
id: string
username: string
isAdmin: boolean
createdAt: Date
updatedAt: Date
}
export const currentUser: Writable<UserResponse | null> = writable(null)
export const accessToken: Writable<string | null> = writable(null)
export const csrfToken: Writable<string | null> = writable(null)
export const isLoading: Writable<boolean> = writable(false)
export const cError: Writable<string | null> = writable(null)
class ApiClient {
private baseUrl: string
private lastAtUpdate = new Date(0)
private lastCsrfUpdate = new Date(0)
private refreshInProgress = false
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
private async handleRequest<T>(
fn: () => Promise<T>,
options: { useBearerAuth: boolean }
): Promise<T | undefined> {
isLoading.set(true)
cError.set(null)
// If `handleResponse` is used, errors thrown from it will be caught here
try {
// Only bearers (JWT access tokens) need to be considered here as the refresh token cookies
// will be automatically included to corresponding requests by browser and CSRF rotation is
// handled by the `refreshAccessToken` method
if (options.useBearerAuth) {
await this.checkAndRefreshAccessToken()
}
return await fn()
} catch (err) {
cError.set(err instanceof Error ? err.message : "Unknown error")
console.log(`error: ${get(cError)}`)
} finally {
isLoading.set(false)
}
}
// Should be attached to routes that handle authentication with the bearer token (access token)
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
if (response.status === 401) {
// This should never happen due to the token expiration checks we make client-side,
// but it's still good to have as a fallback
try {
console.log("unexpected 401 caught, attempting refresh")
await this.refreshAccessToken()
} catch (err) {
console.log("refresh attempt not successful")
await this.handleLocalLogout()
throw new Error("Session expired, please authenticate again.")
}
}
// Capitalize the error message and display (only if not 401)
const { error } = await response.json()
console.log(`response error: ${error}`)
const dError = error[0].toUpperCase() + error.substr(1).toLowerCase() + "."
throw new Error(dError)
}
return response.json()
}
private async checkAndRefreshAccessToken(): Promise<void> {
if (this.refreshInProgress) return
const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime()
const needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF
console.log(`timeSinceUpdate: ${timeSinceUpdate}`)
if (needsRefresh) {
console.log("running token refresh attempt")
this.refreshInProgress = true
await this.refreshAccessToken()
this.refreshInProgress = false
}
}
private async refreshAccessToken(): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/cookie/refresh`, {
method: "POST",
credentials: "include",
headers: {
...(await this.getCsrfHeader())
}
})
if (!response.ok) {
await this.handleLocalLogout()
throw new Error("Refreshing token failed.")
}
const { accessToken: newToken } = await response.json()
accessToken.set(newToken)
this.lastAtUpdate = new Date()
},
{ useBearerAuth: false }
)
}
private async getCsrfHeader(): Promise<HeadersInit> {
let token = get(csrfToken)
const timeSinceUpdate = Date.now() - this.lastCsrfUpdate.getTime()
const needsRefresh = timeSinceUpdate > CSRF_EXP_MS - REFRESH_BUF
if (!token || needsRefresh) {
await this.refreshCsrfToken()
token = get(csrfToken)
}
return { "X-CSRF-Token": token || "" }
}
private async refreshCsrfToken(): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/cookie/csrf`, {
credentials: "include"
})
if (!response.ok) {
await this.handleLocalLogout()
throw new Error("Fetching CSRF token failed.")
}
const newToken = response.headers.get("X-CSRF-Token")
csrfToken.set(newToken)
this.lastCsrfUpdate = new Date()
},
{ useBearerAuth: false }
)
}
private async handleLocalLogout(): Promise<void> {
this.lastAtUpdate = new Date(0)
this.lastCsrfUpdate = new Date(0)
currentUser.set(null)
accessToken.set(null)
csrfToken.set(null)
await goto("/login")
}
private getAuthHeader(): HeadersInit {
const token = get(accessToken)
return { Authorization: `Bearer ${token}` }
}
public async register(username: string, password: string): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
})
// Can't overwrite `username` parameter
const data = await this.handleResponse<{
id: string
username: string
}>(response)
console.log(`'${data.username} -> ${data.id}'`)
await goto("/login")
},
{ useBearerAuth: false }
)
}
public async login(username: string, password: string): Promise<void> {
return this.handleRequest(
async () => {
const params = new URLSearchParams()
params.append("includeUser", "true")
const response = await fetch(`${this.baseUrl}/auth/login?${params}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
})
const { accessToken: token, user } = await this.handleResponse<{
accessToken: string
user: UserResponse
}>(response)
accessToken.set(token)
currentUser.set(user || null)
this.lastAtUpdate = new Date()
console.log(user)
},
{ useBearerAuth: false }
)
}
public async logout(): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/logout`, {
method: "POST",
headers: {
...this.getAuthHeader()
}
})
if (response.status === 204) {
console.log("logout successful")
await this.handleLocalLogout()
return
}
await this.handleResponse<void>(response)
await this.handleLocalLogout()
},
{ useBearerAuth: true }
)
}
public async getCurrentUser(): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/me`, {
headers: {
...this.getAuthHeader()
}
})
const user = await this.handleResponse<UserResponse>(response)
currentUser.set(user)
},
{ useBearerAuth: true }
)
}
public async updateCurrentUserPassword(oldPassword: string, newPassword: string): Promise<void> {
return this.handleRequest(
async () => {
const data = {
old_password: oldPassword,
new_password: newPassword
}
const response = await fetch(`${this.baseUrl}/auth/owner`, {
method: "PUT",
headers: {
...this.getAuthHeader()
},
body: JSON.stringify(data)
})
const { accessToken: token, user } = await this.handleResponse<{
accessToken: string
user: UserResponse
}>(response)
accessToken.set(token)
currentUser.set(user || null)
this.lastAtUpdate = new Date()
console.log(user)
},
{ useBearerAuth: true }
)
}
public async deleteCurrentUser(password: string): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/owner`, {
method: "DELETE",
headers: {
...this.getAuthHeader()
},
body: JSON.stringify({ password })
})
if (response.status === 204) {
console.log("deletion succesful")
await this.handleLocalLogout()
return
}
await this.handleResponse<void>(response)
await this.handleLocalLogout()
},
{ useBearerAuth: true }
)
}
public async adminListAll(): Promise<UserResponse[] | undefined> {
const user = get(currentUser)
if (!user || !user.isAdmin) {
throw new Error("Admin privileges required.")
}
return this.handleRequest(
async () => {
const params = new URLSearchParams()
params.append("limit", `${get(usersPagination).pageSize}`)
params.append("offset", `${get(usersPagination).currentPage}`)
const response = await fetch(`${this.baseUrl}/auth/admin/all`, {
headers: {
...this.getAuthHeader()
}
})
const users = await this.handleResponse<UserResponse[]>(response)
console.log(`admin: got ${users.length} results`)
return users
},
{ useBearerAuth: true }
)
}
public async adminDeleteUser(userID: string): Promise<void> {
const user = get(currentUser)
if (!user || !user.isAdmin) {
throw new Error("Admin privileges required.")
}
if (!UUID_REGEX.test(userID)) {
throw new Error("Invalid user ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/admin/${userID}`, {
method: "DELETE",
headers: {
...this.getAuthHeader()
}
})
if (response.status === 204) {
console.log("admin: deletion successful")
return
}
await this.handleResponse<void>(response)
},
{ useBearerAuth: true }
)
}
}
export const apiClient = new ApiClient(API_BASE_ADDR)

View File

@ -0,0 +1,144 @@
<script lang="ts">
import { cError } from "$lib/client"
import {
ENTROPY_CLASSES,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_ENTROPY,
MIN_PASSWORD_LENGTH
} from "$lib/const"
import { onMount } from "svelte"
export let formName: string
export let handler: (username: string, password: string) => Promise<void>
export let bottomText: string
export let bottomLink: string
let username = ""
let password = ""
let passwordError = ""
let isFormValid = false
// Clear any errors when swapping between login/signup views
onMount(() => cError.set(null))
const calculateEntropy = (password: string) => {
let poolSize = 0
for (const [eClass, poolPlus] of ENTROPY_CLASSES) {
if (eClass.test(password)) {
poolSize += poolPlus
}
}
// Empty password exception
if (poolSize === 0) return 0
const uniqueChars = new Set(password.split("")).size
const basicEntropy = password.length * Math.log2(poolSize)
const diversityAdjustedEntropy =
Math.log2(poolSize) + (password.length - 1) * Math.log2(uniqueChars)
return Math.min(basicEntropy, diversityAdjustedEntropy)
}
const validatePassword = () => {
if (formName === "Login") {
// Skip if logging into existing account
passwordError = ""
isFormValid = true
return
}
if (password.length < MIN_PASSWORD_LENGTH) {
passwordError = `Password cannot be shorter than ${MIN_PASSWORD_LENGTH} characters`
isFormValid = false
return
}
if (password.length > MAX_PASSWORD_LENGTH) {
passwordError = `Password cannot be longer than ${MAX_PASSWORD_LENGTH} characters`
isFormValid = false
return
}
const entropy = calculateEntropy(password)
console.log(`entropy: ${entropy}`)
if (entropy < MIN_PASSWORD_ENTROPY) {
passwordError =
"Password is not complex enough (add uppercase, lowercase, numbers, and symbols)"
isFormValid = false
return
}
passwordError = ""
isFormValid = true
}
// Update validation on password change (reactive dependency)
$: {
password
validatePassword()
}
const handleSubmit = async () => {
cError.set(null)
if (formName === "Register" && !isFormValid) {
cError.set(passwordError)
return
}
try {
await handler(username, password)
} catch (err) {
cError.set(err instanceof Error ? err.message : "Authentication failed")
}
}
</script>
<div class="flex min-h-screen items-center justify-center px-4">
<div class="card w-full max-w-md space-y-6">
<h1 class="text-center text-3xl font-bold">{formName}</h1>
{#if $cError}
<div class="error">
{$cError}
</div>
{/if}
<form class="space-y-6" on:submit|preventDefault={handleSubmit}>
<div class="form-group">
<label for="username" class="form-label"> Username </label>
<input
type="text"
id="username"
bind:value={username}
required
autocomplete="username"
class="w-full"
/>
</div>
<div class="form-group">
<label for="password" class="form-label"> Password </label>
<input
type="password"
id="password"
bind:value={password}
required
autocomplete={formName === "Login" ? "current-password" : "new-password"}
class="w-full"
/>
</div>
<button type="submit" class="btn-primary w-full">
{formName}
</button>
</form>
<p class="text-center text-sm font-medium">
{bottomText}
<a href={bottomLink}> here </a>
</p>
</div>
</div>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import Moon from "$lib/icons/Moon.svelte"
import Sun from "$lib/icons/Sun.svelte"
import { themeState } from "$lib/state.svelte"
import { onMount } from "svelte"
// The `themeState` rune can't be imported here so we must explicitly call the setup
onMount(() => {
themeState.isDarkMode = localStorage.getItem("darkMode") === "true"
})
</script>
<svelte:head>
<script lang="ts">
if (document) {
let darkMode = false
const savedTheme = localStorage.getItem("darkMode")
if (savedTheme !== null) {
darkMode = savedTheme === "true"
} else {
// Fallback to system preference
darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches
localStorage.setItem("darkMode", darkMode ? "true" : "false")
}
document.documentElement.classList.toggle("dark", darkMode)
}
</script>
</svelte:head>
<button
on:click={() => {
themeState.isDarkMode = !themeState.isDarkMode
document.documentElement.classList.toggle("dark", themeState.isDarkMode)
localStorage.setItem("darkMode", themeState.isDarkMode ? "true" : "false")
}}
class="btn-secondary rounded-full p-2"
aria-label={themeState.isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
>
{#if themeState.isDarkMode}
<Moon />
{:else}
<Sun />
{/if}
</button>

24
web/src/lib/const.ts Normal file
View File

@ -0,0 +1,24 @@
// TODO: this can be set to always be "/api" after Dockerization as the backend requests
// will automatically be proxied to the correct destination
export const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api"
// Probably shouldn't be hardcoded, but instead read from .env
export const AT_EXP_MS = 15 * 60 * 1000 // 15 min.
export const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h.
export const REFRESH_BUF = 30 * 1000 // 30 s.
export const MIN_PASSWORD_LENGTH = 12
export const MAX_PASSWORD_LENGTH = 72
export const MIN_PASSWORD_ENTROPY = 60.0
export const UUID_REGEX = new RegExp(
"/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i"
)
// Underestimation compared to the backend to prevent mismatches when registering
export const ENTROPY_CLASSES: Array<[RegExp, number]> = [
[/[a-z]/, 24], // 26
[/[A-Z]/, 24], // 26
[/\d/, 10],
[/[!@#$%^&*()\-_+=\[\]{}|;:'",.<>\/?`~\\]/, 32] // 40
]

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>

After

Width:  |  Height:  |  Size: 184 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clip-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 666 B

20
web/src/lib/pages.ts Normal file
View File

@ -0,0 +1,20 @@
import { writable } from "svelte/store"
interface PaginationState {
currentPage: number
pageSize: number
totalItems?: number // Page number rendering (+ caching metadata)
}
// Limit and offset (`pageSize` and `currentPage`) values align initially with backend defaults,
// but should be adjusted based on the UI state/dimensions
export const notesPagination = writable<PaginationState>({
currentPage: 0,
pageSize: 50
})
export const usersPagination = writable<PaginationState>({
currentPage: 0,
pageSize: 50
})

View File

@ -0,0 +1,3 @@
export const themeState = $state({
isDarkMode: false
})

View File

@ -0,0 +1,12 @@
<script lang="ts">
import ThemeToggle from "$lib/components/ThemeToggle.svelte"
import "../app.css"
let { children } = $props()
</script>
{@render children()}
<div class="absolute top-4 right-4">
<ThemeToggle />
</div>

View File

@ -0,0 +1,2 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

View File

@ -0,0 +1,10 @@
<script lang="ts">
import AuthForm from "$lib/components/AuthForm.svelte"
import { apiClient } from "$lib/client"
const loginHandler = (username: string, password: string) => {
return apiClient.login(username, password)
}
</script>
<AuthForm formName="Login" handler={loginHandler} bottomText="Register" bottomLink="/register" />

View File

View File

@ -0,0 +1,10 @@
<script lang="ts">
import AuthForm from "$lib/components/AuthForm.svelte"
import { apiClient } from "$lib/client"
const registerHandler = (username: string, password: string) => {
return apiClient.register(username, password)
}
</script>
<AuthForm formName="Register" handler={registerHandler} bottomText="Login" bottomLink="/login" />

BIN
web/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
web/svelte.config.js Normal file
View File

@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// 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()
}
};
export default config;

6
web/tailwind.config.cjs Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
darkMode: ["class"],
content: ["./src/**/*.{html,js,svelte,ts}"],
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
theme: {}
}

19
web/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

7
web/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import tailwindcss from "@tailwindcss/vite"
import { sveltekit } from "@sveltejs/kit/vite"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
})