Compare commits

..

1 commit
design ... main

33 changed files with 555 additions and 1343 deletions

View file

@ -22,7 +22,7 @@ tag_metadata = {
} }
@router.post("/login/", response_model=AuthResponseToken) @router.post("/login", response_model=AuthResponseToken)
def login(credentials: UserLoginSchema, db: Session = Depends(get_db)): def login(credentials: UserLoginSchema, db: Session = Depends(get_db)):
"""Returns a JWT for the user whose credentials were provided. """Returns a JWT for the user whose credentials were provided.

View file

@ -2,7 +2,7 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from todo.database.engine import get_db from todo.database.engine import get_db
@ -13,9 +13,6 @@ from todo.utils.exceptions import create_exception_dict as fmt
from todo.dependencies.users import UserSortablePaginationParams from todo.dependencies.users import UserSortablePaginationParams
import todo.auth.auth as auth import todo.auth.auth as auth
import logging
logger = logging.getLogger()
router = APIRouter( router = APIRouter(
prefix="/users", prefix="/users",
@ -51,11 +48,9 @@ def create_user(
@router.get("/{user_id}", response_model=userschema.User) @router.get("/{user_id}", response_model=userschema.User)
def read_user( def read_user(
user_id: int, user_id: int,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user), current_user: userschema.User = Depends(auth_handler.get_current_user),
): ):
logger.error(request)
try: try:
user = usercrud.read_user(db=db, id=user_id) user = usercrud.read_user(db=db, id=user_id)
except NotFoundException as e: except NotFoundException as e:

View file

@ -5,13 +5,12 @@ PUBLIC_TITLE="Example TODO App"
PUBLIC_DESCRIPTION="An example TODO app built with sveltekit and fastapi." PUBLIC_DESCRIPTION="An example TODO app built with sveltekit and fastapi."
PUBLIC_AUTHOR="John Doe" PUBLIC_AUTHOR="John Doe"
# These set how node serves the sveltekit app
HOST="0.0.0.0" HOST="0.0.0.0"
PORT="3000" PORT="3000"
# WARNING: only set these if running behind a trusted reverse proxy! # WARNING: only set these if running behind a trusted reverse proxy!
# See `https://kit.svelte.dev/docs/adapter-node#environment-variables-origin-protocol-header-and-host-header`. # See `https://kit.svelte.dev/docs/adapter-node#environment-variables-origin-protocol-header-and-host-header`.
#ORIGIN=http://localhost ORIGIN=http://localhost
PROTOCOL_HEADER="x-forwarded-proto" PROTOCOL_HEADER="x-forwarded-proto"
HOST_HEADER="x-forwarded-host" HOST_HEADER="x-forwarded-host"

View file

@ -1,5 +1,5 @@
# See `https://kit.svelte.dev/docs/adapter-node#environment-variables-origin-protocol-header-and-host-header`. # See `https://kit.svelte.dev/docs/adapter-node#environment-variables-origin-protocol-header-and-host-header`.
ORIGIN=http://localhost:8000 ORIGIN=http://localhost
# See `https://kit.svelte.dev/docs/adapter-node#environment-variables-address-header-and-xff-depth`. # See `https://kit.svelte.dev/docs/adapter-node#environment-variables-address-header-and-xff-depth`.
ADDRESS_HEADER="X-Forwarded-For" ADDRESS_HEADER="X-Forwarded-For"

View file

@ -1,47 +0,0 @@
GIMP Palette
Name: colorscheme.gpl
Columns: 1
#
213 232 236 primary-50
156 202 211 primary-100
75 154 170 primary-200
50 102 113 primary-300
31 64 71 primary-400
13 27 30 primary-500 (default)
12 25 28 primary-600
7 15 17 primary-700
4 8 9 primary-800
0 0 0 primary-900
243 247 245 secondary-50
219 230 226 secondary-100
182 205 197 secondary-200
158 189 178 secondary-300
134 172 159 secondary-400
108 154 139 secondary-500 (default)
91 134 119 secondary-600
74 109 97 secondary-700
58 85 75 secondary-800
25 36 32 secondary-900
255 249 235 accent-50
255 237 194 accent-100
255 231 173 accent-200
255 218 133 accent-300
255 206 92 accent-400
255 194 51 accent-500 (default)
255 182 10 accent-600
224 157 0 accent-700
163 114 0 accent-800
82 57 0 accent-900
255 255 255 background-50
255 255 255 background-100
255 255 255 background-200
255 255 255 background-300
255 255 255 background-400
252 252 252 background-500 (default)
204 204 204 background-600
163 163 163 background-700
112 112 112 background-800
51 51 51 background-900
0 204 0 success
237 192 0 warning
228 0 0 failure

View file

@ -8,13 +8,6 @@
/* Global defaults */ /* Global defaults */
@layer base { @layer base {
body {
@apply bg-background;
@apply text-primary;
@apply flex flex-col gap-4 justify-between;
@apply min-h-screen;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
@apply font-bold text-primary; @apply font-bold text-primary;
} }
@ -39,10 +32,11 @@
} }
a { a {
@apply underline text-primary-300; @apply underline text-secondary-500;
} }
a:visited { a:visited {
@apply underline text-primary-400; @apply text-secondary-700;
} }
} }
@ -50,82 +44,25 @@
button { button {
@apply p-2; @apply p-2;
@apply rounded-md; @apply rounded-md;
@apply bg-accent; @apply bg-neutral-100;
@apply text-primary-400 font-semibold; @apply border-2 border-neutral-200;
@apply drop-shadow-md;
@apply transition-all ease-in-out; @apply transition-all ease-in-out;
} }
button:hover { button:hover {
@apply bg-accent-600; @apply bg-neutral-200;
@apply drop-shadow-lg; @apply border-neutral-300;
@apply text-primary font-bold;
} }
button:active { button:active {
@apply bg-accent-700; @apply bg-neutral-300;
@apply drop-shadow-none; @apply border-neutral-400;
@apply text-primary-600;
} }
button:focus { button:focus {
@apply ring-2 ring-accent-600; @apply ring-1 ring-neutral-400 ring-offset-1;
} }
button:disabled { button:disabled {
@apply opacity-25;
@apply cursor-not-allowed;
}
button:disabled:hover {
@apply bg-accent;
@apply text-primary-400 font-semibold;
@apply cursor-not-allowed;
}
button.outlineButton {
@apply bg-secondary/20;
@apply border-2 border-secondary;
@apply text-secondary font-medium;
}
button.outlineButton:hover {
@apply bg-secondary;
@apply text-secondary-900 font-bold;
}
button.outlineButton:active {
@apply bg-secondary-600;
@apply text-secondary-900;
}
button.outlineButton:focus {
@apply ring-secondary-400;
}
button.outlineButton:disabled {
@apply opacity-25;
}
button.outlineButton:disabled:hover {
@apply bg-secondary/20;
@apply text-secondary font-medium;
@apply cursor-not-allowed;
}
a.button {
@apply p-2;
@apply rounded-md;
@apply bg-accent;
@apply text-primary-400 font-semibold;
@apply no-underline;
@apply drop-shadow-md;
@apply transition-all ease-in-out;
}
a.button:hover {
@apply bg-accent-600;
@apply drop-shadow-lg;
@apply text-primary font-bold;
}
a.button:active {
@apply bg-accent-700;
@apply drop-shadow-none;
@apply text-primary-600;
}
a.button:focus {
@apply ring-2 ring-accent-600;
}
a.button:disabled {
@apply opacity-25; @apply opacity-25;
@apply bg-neutral-100; @apply bg-neutral-100;
@apply border-2 border-neutral-200;
} }
table { table {
@ -143,65 +80,43 @@
tr:last-of-type td:last-of-type { tr:last-of-type td:last-of-type {
@apply rounded-br-md; @apply rounded-br-md;
} }
tr {
@apply hover:bg-secondary-300/50;
@apply border-y border-primary/50;
@apply bg-secondary-100/10;
}
tr:first-of-type, tr:last-of-type { tr:first-of-type, tr:last-of-type {
@apply border-y-0; @apply border-y-0;
} }
tr:nth-child(odd) {
@apply bg-secondary/5;
}
tr:nth-child(even) {
@apply bg-secondary/20;
}
tr {
@apply hover:bg-accent/25;
}
th { th {
@apply bg-primary font-semibold text-secondary-100; @apply bg-primary font-semibold text-secondary-100;
}
caption, th, td {
@apply p-1; @apply p-1;
} }
td { @media screen(sm) {
@apply p-1; caption, th, td {
@apply p-2 text-start;
} }
caption {
@apply p-1 text-start font-semibold text-xl;
} }
fieldset { fieldset {
@apply border border-primary/50; @apply border border-secondary-300;
@apply rounded-lg; @apply p-2;
@apply p-4;
} }
legend { legend {
@apply text-primary/50 font-light; @apply text-black/50;
} }
input { input {
@apply text-primary font-semibold;
@apply border border-secondary; @apply border border-secondary;
@apply bg-secondary/20; @apply bg-secondary-50;
@apply rounded-md; @apply rounded-md;
@apply p-1; @apply p-1;
} }
input::placeholder {
@apply text-secondary/50 font-light;
}
input:focus { input:focus {
@apply outline-none border-none ring-2 ring-secondary; @apply outline-none border-none ring-2 ring-secondary-400;
} }
label { label {
@apply text-sm text-secondary font-light; @apply text-sm text-secondary;
}
select {
@apply text-primary font-semibold;
@apply border border-secondary;
@apply bg-secondary/20;
@apply rounded-md;
@apply p-1;
}
ul {
@apply list-inside list-disc;
}
ol {
@apply list-inside list-decimal;
} }
} }

View file

@ -1,167 +1,182 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { ItemCount, TodoItem, Token, User } from './types'; import type { ItemCount, TodoItem, User } from './types';
import { Endpoint } from './types'; import { getResponseBodyOrError } from '$lib/api/utils';
import type { StringMapping } from '$lib/utils/types'; import { getTokenFromLocalstorage } from '$lib/auth/session';
import { _getTokenFromLocalstorage } from '$lib/auth/session';
/** /**
* A factory class for creating `Endpoint` instances to interact with the backend API. * Retrieves the currently logged in user's JWT from localstorage,
* or throws an error if it is not present.
* *
* This class provides methods to create specific `Endpoint` instances for different API operations, * @throws {error} - If the current user is not logged in or if their JWT is not saved in localstorage.
* such as retrieving user data, retrieving todo items, and more. It follows the factory pattern to
* encapsulate the creation of `Endpoint` instances with the necessary configurations and headers
* for making API requests.
*
* The `EndpointFactory` constructor accepts an optional `fetchFunction` parameter, which is the
* function used for making API requests. If no `fetchFunction` is provided, the default `fetch`
* function is used.
*
* Example usage:
* ```typescript
* const endpointFactory = new EndpointFactory();
*
* const readUserEndpoint = endpointFactory.createReadUserEndpoint(123);
* const users = await readUserEndpoint.call();
*
* const readTodosEndpoint = endpointFactory.createReadTodosEndpoint(123);
* const todos = await readTodosEndpoint.call();
* ```
*/ */
export class EndpointFactory { function getTokenOrError(): string {
let token = getTokenFromLocalstorage();
/** The function to use for making API requests. */ if (token === null) {
readonly fetchFunction: Function;
// The JSON web token to send as an authorization bearer token.
private _jwt: string | null;
/**
* Constructs a new `EndpointFactory` instance.
* @param fetchFunction - The function Endpoints created by this factory use for making API requests. (Default: fetch)
*/
constructor(fetchFunction: Function = fetch) {
this.fetchFunction = fetchFunction;
this._jwt = _getTokenFromLocalstorage();
}
private _getDefaultHeaders(): StringMapping {
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
}
private _getDefaultHeadersWithAuth(): StringMapping {
if (this._jwt === null) {
throw error(401, 'You are not logged in.'); throw error(401, 'You are not logged in.');
} }
return token;
}
let headers = this._getDefaultHeaders(); /**
headers['Authorization'] = `Bearer: ${this._jwt}`; * Retrieves the user with the specified ID from the backend API.
return headers;
}
/**
* Creates an Endpoint which retrieves the user with the specified ID from the backend API.
* *
* @param {number} userId - The ID of the user whom to retrieve. * @param {number} userId - The ID of the user whom to retrieve.
* @throws {error} If the user is not currently logged in. * @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/ */
createReadUserEndpoint(userId: number): Endpoint<User> { export async function readUser(userId: number, jwt: string = getTokenOrError()): Promise<User> {
return new Endpoint<User>( const endpoint = `/api/users/${userId}`;
`/api/users/${userId}`, const response = await fetch(endpoint, {
'GET', method: 'GET',
this._getDefaultHeadersWithAuth(), headers: {
this.fetchFunction 'Accept': 'application/json',
); 'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
} }
});
/** const responseJson = await getResponseBodyOrError(response);
* Creates an Endpoint which retrieves the list of all users from the backend API. return responseJson as User;
* }
* @throws {error} If the user is not currently logged in.
*/ /**
createReadUsersEndpoint(): Endpoint<User[]> { * Retrieves the list of all users from the backend API.
return new Endpoint<User[]>( *
`/api/admin/users/`, * @param {string} jwt - The JWT appended as a bearer token to authorize the request.
'GET', * @throws{error} - If the request fails or is not permitted.
this._getDefaultHeadersWithAuth(), */
this.fetchFunction export async function readUsers(
); skip: number,
} limit: number,
sortby: string,
/** sortorder: string,
* Creates an Endpoint which retrieves the total user count from the backend API. jwt: string = getTokenOrError(),
* ): Promise<User[]> {
* @throws {error} If the user is not currently logged in.
*/ const urlParameters = new URLSearchParams({
createReadUserCountEndpoint(): Endpoint<ItemCount> { skip: `${skip}`,
return new Endpoint<ItemCount>( limit: `${limit}`,
`/api/admin/users/total/`, sortby: `${sortby}`,
'GET', sortorder: `${sortorder}`,
this._getDefaultHeadersWithAuth(), });
this.fetchFunction const endpoint = `/api/admin/users/?${urlParameters}`;
);
} const response = await fetch(endpoint, {
method: 'GET',
/** headers: {
* Creates an Endwpoint which retrieves the list of all todo-items for the user with the specified ID from the backend API. 'Accept': 'application/json',
* 'Authorization': `Bearer ${jwt}`,
* @param {number} userId - The ID of the user whose todo-items to retrieve. 'Content-Type': 'application/json',
* @throws {error} If the user is not currently logged in. }
*/ });
createReadTodosEndpoint(userId: number): Endpoint<TodoItem[]> {
return new Endpoint<TodoItem[]>( const responseJson = await getResponseBodyOrError(response);
`/api/todos/user/${userId}`, return responseJson as User[];
'GET', }
this._getDefaultHeadersWithAuth(),
this.fetchFunction /**
); * Retrieves the total user count from the backend API.
} *
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
/** * @throws{error} - If the request fails or is not permitted.
* Creates an Endpoint which retrieves the total todo-item count for the user with the specified ID from the backend API. */
* export async function readUserCount(jwt: string = getTokenOrError()): Promise<number> {
* @param {number} userId - The ID of the user whose todo-items to retrieve. const endpoint = '/api/admin/users/total/';
* @throws {error} If the user is not currently logged in. const response = await fetch(endpoint, {
*/ method: 'GET',
createReadTodoCountEndpoint(userId: number): Endpoint<ItemCount> { headers: {
return new Endpoint<ItemCount>( 'Accept': 'application/json',
`/api/todos/user/${userId}/total/`, 'Authorization': `Bearer ${jwt}`,
'GET', 'Content-Type': 'application/json',
this._getDefaultHeadersWithAuth(), }
this.fetchFunction });
);
} const responseJson = await getResponseBodyOrError(response);
const itemCount = responseJson as ItemCount;
/** return itemCount.total;
* Creates an Endpoint which retrieves the todo-item with the specified ID from the backend API. }
*
* @param {number} todoId - The ID of the todo-item to be retrieved. /**
* @throws {error} If the user is not currently logged in. * Retrieves the list of all todo-items for the user with the specified ID from the backend API.
*/ *
createReadTodoEndpoint(todoId: number): Endpoint<TodoItem> { * @param {string} jwt - The JWT appended as a bearer token to authorize the request.
return new Endpoint<TodoItem>( * @throws{error} - If the request fails or is not permitted.
`/api/todos/${todoId}`, */
'GET', export async function readTodos(
this._getDefaultHeadersWithAuth(), userId: number,
this.fetchFunction skip: number,
); limit: number,
} sortby: string,
sortorder: string,
/** jwt: string = getTokenOrError(),
* Creates an Endpoint which sends an API request containing the specified login credentials ): Promise<TodoItem[]> {
* to the backend, and returns the JWT retrieved from the response on sucess.
* const urlParameters = new URLSearchParams({
* @throws {error} - If the request fails or is not permitted. skip: `${skip}`,
*/ limit: `${limit}`,
createLoginEndpoint(): Endpoint<Token> { sortby: `${sortby}`,
return new Endpoint<Token>( sortorder: `${sortorder}`,
`/api/auth/login/`, });
'POST', const endpoint = `/api/todos/user/${userId}?${urlParameters}`;
this._getDefaultHeaders(),
this.fetchFunction const response = await fetch(endpoint, {
); method: 'GET',
} headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as TodoItem[];
}
/**
* Retrieves the total todo-item count for the user with the specified ID from the backend API.
*
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readTodoCount(
userId: number,
jwt: string = getTokenOrError()
): Promise<number> {
const endpoint = `/api/todos/user/${userId}/total/`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
const itemCount = responseJson as ItemCount;
return itemCount.total;
}
/**
* Retrieves the todo-item with the specified ID from the backend API.
*
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readTodo(
todoId: number,
jwt: string = getTokenOrError()
): Promise<TodoItem> {
const endpoint = `/api/todos/${todoId}`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as TodoItem;
} }

View file

@ -1,23 +1,7 @@
import type { StringMapping } from '$lib/utils/types';
import { error } from '@sveltejs/kit';
/**
* An API response indicating an item count.
*/
export type ItemCount = { export type ItemCount = {
total: number total: number
} }
/**
* An API response indicating a token.
*/
export type Token = {
token: string
}
/**
* An API response representing a User object.
*/
export type User = { export type User = {
id: number, id: number,
email: string, email: string,
@ -28,9 +12,6 @@ export type User = {
is_admin: boolean, is_admin: boolean,
} }
/**
* An API response representing a TodoItem object.
*/
export type TodoItem = { export type TodoItem = {
id: number, id: number,
title: string, title: string,
@ -40,88 +21,3 @@ export type TodoItem = {
updated: Date, updated: Date,
finished: Date, finished: Date,
} }
/**
* Allowed API-request request types.
*/
type RequestMethod = 'GET' | 'POST' | 'UPDATE' | 'DELETE';
/**
* Allowed options to an Enpoint's `call()`-method
*/
type EndpointCallOptions = {
body?: any,
queryParameters?: StringMapping
}
/**
* Represents an external API endpoint.
*/
export class Endpoint<T> {
/** The URL of the API endpoint. */
readonly url: string;
/** The request method to be used for API calls. */
readonly requestMethod: RequestMethod;
/** The headers to be included in API requests. */
readonly requestHeaders: StringMapping;
/** The function to use for making API requests. */
readonly fetchFunction: Function;
/**
* Constructs a new `Endpoint` instance.
* @param url - The URL of the API endpoint.
* @param requestMethod - The request method to be used for API calls. (Default: 'GET')
* @param requestHeaders - The headers to be included in API requests. (Default: {})
* @param fetchFunction - The function to use for making API requests. (Default: fetch)
*/
constructor(
url: string,
requestMethod: RequestMethod = 'GET',
requestHeaders: StringMapping = {},
fetchFunction: Function = fetch
) {
this.url = url;
this.requestMethod = requestMethod;
this.requestHeaders = requestHeaders;
this.fetchFunction = fetchFunction;
}
/**
* Calls the API endpoint with optional query parameters.
*
* @param queryParameters - The query parameters to be included in the API call. (Default: new URLSearchParams({}))
* @returns A Promise that resolves to the JSON response from the API.
* @throws {error} If the API request fails or returns an error response.
*/
async call(options: EndpointCallOptions = {}): Promise<T> {
let endpointUrl = this.url;
if ('queryParameters' in options) {
endpointUrl += `?${new URLSearchParams(options.queryParameters)}`;
}
const response = await this.fetchFunction(endpointUrl, {
method: this.requestMethod,
headers: this.requestHeaders,
body: 'body' in options ? JSON.stringify(options.body) : null
});
const responseJson = await response.json();
if (!response.ok) {
if ('detail' in responseJson) {
if (typeof responseJson.detail === 'string') {
throw error(response.status, responseJson.detail);
} else if (Array.isArray(responseJson.detail)) {
if ('msg' in responseJson.detail[0] && typeof responseJson.detail[0].msg === 'string') {
throw error(response.status, responseJson.detail[0].msg);
}
}
}
throw error(response.status, `API request failed: ${response.statusText}`);
}
return responseJson as T;
}
}

View file

@ -1,5 +1,5 @@
import { getResponseBodyOrError } from '$lib/api/utils'; import { getResponseBodyOrError } from '$lib/api/utils';
import { EndpointFactory } from '$lib/api/endpoints'; import { readUser } from '$lib/api/endpoints';
import jwt_decode from 'jwt-decode'; import jwt_decode from 'jwt-decode';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
@ -9,10 +9,10 @@ import { writable } from 'svelte/store';
export const storedUser = writable(); export const storedUser = writable();
// Name of the key holding the auth JWT in localstorage // Name of the key holding the auth JWT in localstorage
const _jwtKey = 'jwt'; const jwtKey = 'jwt';
// Name of the key holding the authenticated user in localstorage // Name of the key holding the authenticated user in localstorage
const _userKey = 'user'; const userKey = 'user';
export type StoredUser = { export type StoredUser = {
id: number, id: number,
email: string, email: string,
@ -25,22 +25,22 @@ export type StoredUser = {
* *
* @param {string} token - The token to save in localstorage. * @param {string} token - The token to save in localstorage.
*/ */
function _saveTokenToLocalstorage(token: string): void { function saveTokenToLocalstorage(token: string): void {
localStorage.setItem(_jwtKey, token); localStorage.setItem(jwtKey, token);
} }
/** /**
* Retrieves and returns the token, if present, from localstorage. * Retrieves and returns the token, if present, from localstorage.
*/ */
export function _getTokenFromLocalstorage(): string | null { export function getTokenFromLocalstorage(): string | null {
return localStorage.getItem(_jwtKey); return localStorage.getItem(jwtKey);
} }
/** /**
* Removes the saved token from localstorage. * Removes the saved token from localstorage.
*/ */
function clearTokenInLocalstorage(): void { function clearTokenInLocalstorage(): void {
localStorage.removeItem(_jwtKey); localStorage.removeItem(jwtKey);
} }
/** /**
@ -49,14 +49,14 @@ function clearTokenInLocalstorage(): void {
* @param {StoredUser} user - The user to write to localstorage. * @param {StoredUser} user - The user to write to localstorage.
*/ */
function saveUserToLocalstorage(user: StoredUser): void { function saveUserToLocalstorage(user: StoredUser): void {
localStorage.setItem(_userKey, JSON.stringify(user)); localStorage.setItem(userKey, JSON.stringify(user));
} }
/** /**
* Retrieves and returns the user, if present, from localstorage. * Retrieves and returns the user, if present, from localstorage.
*/ */
export function getUserFromLocalstorage(): StoredUser | null { function getUserFromLocalstorage(): StoredUser | null {
let item: string | null = localStorage.getItem(_userKey); let item: string | null = localStorage.getItem(userKey);
if (typeof item !== 'string') { if (typeof item !== 'string') {
return null; return null;
} }
@ -67,7 +67,36 @@ export function getUserFromLocalstorage(): StoredUser | null {
* Removes the saved user from localstorage. * Removes the saved user from localstorage.
*/ */
function clearUserInLocalstorage(): void { function clearUserInLocalstorage(): void {
localStorage.removeItem(_userKey); localStorage.removeItem(userKey);
}
/**
* Sends an API request containing the specified login credentials to the backend,
* and returns the JWT retrieved from the response on sucess.
*
* @param {string} email - The email address of the user whose JWT to retrieve.
* @param {string} password - The password used to authenticate the user whose JWT is being retrieved.
* @throws {Error} - If the API request failed or the supplied credentials were invalid.
*/
async function requestJwt(email: string, password: string): Promise<string> {
type Token = {
token: string
}
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
password: password,
})
});
const responseBody = await getResponseBodyOrError(response) as Token;
return responseBody.token;
} }
/** /**
@ -96,36 +125,23 @@ function clearUserFromStore(): void {
* @throws {Error} - If any API request fails or the supplied credentials were invalid. * @throws {Error} - If any API request fails or the supplied credentials were invalid.
*/ */
export async function login(email: string, password: string): Promise<void> { export async function login(email: string, password: string): Promise<void> {
let endpointFactory = new EndpointFactory(); const token = await requestJwt(email, password);
const loginEndpoint = endpointFactory.createLoginEndpoint();
const token = await loginEndpoint.call({
body: {
email: email,
password: password,
}
});
interface Token { interface Token {
sub: number, sub: number,
exp: number exp: number
} }
const parsedToken = jwt_decode(token.token) as Token; const parsedToken = jwt_decode(token) as Token;
const userId = parsedToken.sub; const userId = parsedToken.sub;
_saveTokenToLocalstorage(token.token); const user = await readUser(userId, token);
// recreate the factory with the jwt now in localstorage
endpointFactory = new EndpointFactory();
const readUserEndpoint = endpointFactory.createReadUserEndpoint(userId);
console.log(readUserEndpoint)
console.log(await readUserEndpoint.call())
const user = await readUserEndpoint.call();
saveTokenToLocalstorage(token);
saveUserToLocalstorage({ saveUserToLocalstorage({
id: user.id, id: user.id,
email: user.email, email: user.email,
isAdmin: user.is_admin, isAdmin: user.is_admin,
sessionExpires: new Date(parsedToken.exp * 1000), sessionExpires: new Date(parsedToken.exp),
}); });
loadUserIntoStore(); loadUserIntoStore();
} }

View file

@ -3,9 +3,8 @@
import { Icon, ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft } from 'svelte-hero-icons'; import { Icon, ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft } from 'svelte-hero-icons';
import Th from '$lib/components/data-table/Th.svelte'; import Th from '$lib/components/data-table/Th.svelte';
import Td from '$lib/components/data-table/Td.svelte'; import Td from '$lib/components/data-table/Td.svelte';
import type { Column } from '$lib/components/data-table/types'; import type { Column, Endpoint } from '$lib/components/data-table/types';
import { interpolateString as i } from '$lib/components/data-table/utils'; import { interpolateString as i } from '$lib/components/data-table/utils';
import type { Endpoint, ItemCount } from '$lib/api/types';
/** /**
* The caption for the data table. * The caption for the data table.
@ -20,35 +19,16 @@
* The function which fetches rows for the data table. * The function which fetches rows for the data table.
* Must support pagination and sorting. * Must support pagination and sorting.
*/ */
export let getItemsEndpoint: Endpoint<any>; export let getItemsEndpoint: Endpoint = {
callable: () => [],
args: [],
}
/** /**
* The function which fetches the total number of items. Used for pagination. * The function which fetches the total number of items. Used for pagination.
*/ */
export let getItemCountEndpoint: Endpoint<ItemCount>; export let getItemCountEndpoint: Endpoint = {
callable: () => 0,
/** args: [],
* The name of the field by which table entries are sorted by default.
*/
export let defaultSortField: string = columns[0].field;
/**
* The sort order by which table entries are sorted by default.
*/
export let defaultSortOrder: 'asc' | 'desc' = 'asc';
let viewportWidth: number = 0;
let table: Element;
let tableMaxWidth: number = 0;
let compactView = false;
function checkForTableResize() {
if (!table) return;
if (table.scrollWidth > tableMaxWidth) {
tableMaxWidth = table.scrollWidth;
}
compactView = tableMaxWidth >= viewportWidth;
} }
type SortEvent = { type SortEvent = {
@ -57,11 +37,11 @@
field: string, field: string,
} }
} }
let currentSortField: string = defaultSortField; let currentSortField: string = columns[0].field;
let currentSortOrder: 'asc' | 'desc' = defaultSortOrder; let currentSortOrder: 'asc' | 'desc' = 'asc';
let currentState: 'finished' | 'loading' | 'error' = 'finished'; let currentState: 'finished' | 'loading' | 'error' = 'finished';
let errorMessage: string; let errorMessages: string[] = [];
let currentItems: any[] = []; // TODO apply generics here let currentItems: any[] = []; // TODO apply generics here
let totalItemCount: number | null = null; let totalItemCount: number | null = null;
@ -142,28 +122,27 @@
async function updateTable() { async function updateTable() {
try { try {
currentState = 'loading'; currentState = 'loading';
totalItemCount = await getItemCountEndpoint.callable(
let itemCountResponse = await getItemCountEndpoint.call(); ...getItemCountEndpoint.args,
totalItemCount = itemCountResponse.total; );
currentItems = await getItemsEndpoint.callable(
let itemsResponse = await getItemsEndpoint.call({ ...getItemsEndpoint.args,
queryParameters: { currentItemsOffset,
skip: `${currentItemsOffset}`, currentItemsPerPage,
limit: `${currentItemsPerPage}`, currentSortField,
sortby: `${currentSortField}`, currentSortOrder
sortorder: `${currentSortOrder}`, );
}
});
currentItems = itemsResponse;
currentState = 'finished'; currentState = 'finished';
} catch (error: any) { } catch (error: any) {
if (typeof error.body.message === 'string') { if (!errorMessages.length) {
errorMessage = error.body.message; // Make sure we have an error message to show to the user
if (typeof error.message === 'string') {
errorMessages.push(error.message);
} else { } else {
errorMessage = "An unknown error occurred."; errorMessages.push("An unknown error occurred.");
} }
console.log(error) }
currentState = 'error'; currentState = 'error';
} }
} }
@ -176,41 +155,14 @@
} }
onMount(async () => { onMount(async () => {
checkForTableResize();
await updateTable(); await updateTable();
checkForTableResize();
}); });
</script> </script>
<svelte:window bind:outerWidth={viewportWidth} on:resize={checkForTableResize} /> <table>
<div id="table-container"
class="flex flex-col items-center gap-2"
class:compact-view={compactView}
class:extended-view={!compactView}
>
<table
bind:this={table}
>
{#if caption !== null} {#if caption !== null}
<caption>{caption}</caption> <caption>{caption}</caption>
{/if} {/if}
{#if compactView}
<div class="table-controls m-2">
<span>Sort by:</span>
<select bind:value={currentSortField} on:change={updateTable} class="mr-2">
{#each columns as column}
<option selected={column.field === currentSortField} value={column.field}>{column.heading}</option>
{/each}
</select>
<span>Order:</span>
<select bind:value={currentSortOrder} on:change={updateTable}>
<option selected={currentSortOrder === 'asc'} value={'asc'}>Ascending</option>
<option selected={currentSortOrder === 'desc'} value={'desc'}>Descending</option>
</select>
</div>
{/if}
{#if !compactView}
<thead> <thead>
<tr> <tr>
{#each columns as column} {#each columns as column}
@ -224,22 +176,16 @@
{/each} {/each}
</tr> </tr>
</thead> </thead>
{/if} <tbody>
<tbody
class:compact-view={compactView}
class:extended-view={!compactView}
>
{#if currentState === 'finished'} {#if currentState === 'finished'}
{#each currentItems as item} {#each currentItems as item}
<tr> <tr>
{#each columns as column} {#each columns as column}
<Td <Td
fieldName={column.heading}
data={item[column.field]} data={item[column.field]}
type={column.type ?? 'string'} type={column.type ?? 'string'}
isLink={column.isLink ?? false} isLink={column.isLink ?? false}
linkTarget={i(column.linkTarget, item) ?? ''} linkTarget={i(column.linkTarget, item) ?? ''}
compactView={compactView}
/> />
{/each} {/each}
</tr> </tr>
@ -249,67 +195,33 @@
<td colspan={columns.length}>Loading...</td> <td colspan={columns.length}>Loading...</td>
</tr> </tr>
{:else if currentState === 'error'} {:else if currentState === 'error'}
{#each errorMessages as message}
<tr> <tr>
<td colspan={columns.length} class="text-center text-failure font-bold">{errorMessage}</td> <td colspan={columns.length} class="text-center text-red-500">{message}</td>
</tr> </tr>
{/each}
{/if} {/if}
</tbody> </tbody>
</table> </table>
<div class="table-controls"> <button on:click={handleGotoFirstPage} disabled={gotoFirstPageDisabled}>
<label for="itemsPerPageSelector" class="text-lg">Items per page</label> <Icon src={ChevronDoubleLeft} mini class="w-6 h-6" />
<select id="itemsPerPageSelector" bind:value={selectedItemsPerPage} on:change={handleItemsPerPageChange} class="mr-2"> </button>
<button on:click={handleGotoPrevPage} disabled={gotoPrevPageDisabled}>
<Icon src={ChevronLeft} mini class="w-6 h-6" />
</button>
<input
type=number bind:value={selectedCurrentPage} min=0 max={lastPage} disabled={lastPage === 0}
on:change={handleCurrentPageInputChanged} on:focus={handleCurrentPageInputFocussed}
>
<span>of {lastPage + 1}</span>
<button on:click={handleGotoNextPage} disabled={gotoNextPageDisabled}>
<Icon src={ChevronRight} mini class="w-6 h-6" />
</button>
<button on:click={handleGotoLastPage} disabled={gotoLastPageDisabled}>
<Icon src={ChevronDoubleRight} mini class="w-6 h-6" />
</button>
<select bind:value={selectedItemsPerPage} on:change={handleItemsPerPageChange}>
{#each itemsPerPageOptions as option} {#each itemsPerPageOptions as option}
<option selected={option === currentItemsPerPage} value={option}>{option}</option> <option selected={option === currentItemsPerPage} value={option}>{option}</option>
{/each} {/each}
</select> </select>
<span>Total: <b>{totalItemCount}</b></span>
</div>
<div class="table-controls">
<button on:click={handleGotoFirstPage} disabled={gotoFirstPageDisabled} class="outlineButton">
<Icon src={ChevronDoubleLeft} mini class="w-6 h-6" />
</button>
<button on:click={handleGotoPrevPage} disabled={gotoPrevPageDisabled} class="outlineButton">
<Icon src={ChevronLeft} mini class="w-6 h-6" />
</button>
<label for="currentPageNumberInput" class="text-lg">Page</label>
<input id="currentPageNumberInput"
type=number bind:value={selectedCurrentPage} min=0 max={lastPage} disabled={lastPage === 0}
on:change={handleCurrentPageInputChanged} on:focus={handleCurrentPageInputFocussed}
class="text-center"
>
<span>of {lastPage + 1}</span>
<button on:click={handleGotoNextPage} disabled={gotoNextPageDisabled} class="outlineButton">
<Icon src={ChevronRight} mini class="w-6 h-6" />
</button>
<button on:click={handleGotoLastPage} disabled={gotoLastPageDisabled} class="outlineButton">
<Icon src={ChevronDoubleRight} mini class="w-6 h-6" />
</button>
</div>
</div>
<style>
option {
@apply text-center;
}
#table-container.compact-view {
@apply max-w-fit;
@apply p-4;
}
#table-container.extended-view {
@apply max-w-max;
}
.compact-view caption {
@apply text-center;
}
.compact-view tr {
@apply border border-primary;
}
div.table-controls {
@apply flex gap-1 items-center justify-center;
}
.table-controls span {
@apply text-lg text-secondary font-light;
}
</style>

View file

@ -1,76 +1,30 @@
<script lang="ts"> <script lang="ts">
import { Icon, Check, Minus, XMark } from 'svelte-hero-icons'; import { Icon, Check, XMark } from 'svelte-hero-icons';
export let fieldName: string;
export let data: any; export let data: any;
export let type: 'string' | 'date' | 'number' | 'boolean' = 'string'; export let type: 'string' | 'date' | 'boolean' = 'string';
export let isLink: boolean = false; export let isLink: boolean = false;
export let linkTarget: string = ""; export let linkTarget: string = "";
export let compactView = false;
$: extendedView = !compactView;
</script> </script>
<td <td>
class:compact-view={compactView}
class:extended-view={extendedView}
class:boolean-data={type === 'boolean'}
class:string-data={type === 'string'}
class:number-data={type === 'number'}
class:date-data={type === 'date'}
class:empty-data={data === null || data === undefined}
>
{#if compactView}
<p class="field-name">{fieldName}:</p>
{/if}
{#if data === null || data === undefined}
<Icon src={Minus} mini class="field-data w-6 h-6 text-primary/40" />
{:else}
{#if type === 'date'} {#if type === 'date'}
<p class="field-data">{new Date(data).toLocaleDateString()}</p> {#if data !== null && data !== undefined}
{new Date(data).toLocaleDateString()}
{:else}
<!-- date undefined -->
{/if}
{:else if type === 'boolean'} {:else if type === 'boolean'}
{#if data} {#if data}
<Icon src={Check} mini class="field-data w-6 h-6 text-success/50" /> <Icon src={Check} mini class="w-6 h-6" />
{:else} {:else}
<Icon src={XMark} mini class="field-data w-6 h-6 text-failure/50" /> <Icon src={XMark} mini class="w-6 h-6" />
{/if} {/if}
{:else} {:else}
{#if isLink} {#if isLink}
<a href={linkTarget} class="field-data">{data}</a> <a href={linkTarget}>{data}</a>
{:else} {:else}
<p class="field-data">{data}</p> {data}
{/if}
{/if} {/if}
{/if} {/if}
</td> </td>
<style>
td.compact-view {
@apply grid grid-cols-2 items-center justify-start gap-x-2;
}
.compact-view .field-name {
@apply text-primary/50 font-light;
@apply text-end;
}
.compact-view .field-data {
@apply truncate;
@apply font-medium;
}
td.compact-view.date-data .field-data, td.compact-view.number-data .field-data {
@apply text-start;
}
td.extended-view {
@apply table-cell;
}
td.extended-view.boolean-data, td.extended-view.empty-data {
@apply flex justify-center;
}
td.extended-view.date-data .field-data, td.extended-view.number-data .field-data {
@apply text-end;
}
</style>

View file

@ -6,3 +6,8 @@ export type Column = {
isLink?: boolean, isLink?: boolean,
linkTarget?: string, linkTarget?: string,
} }
export type Endpoint = {
callable: Function,
args: any[],
}

View file

@ -1,14 +0,0 @@
<script lang="ts">
</script>
<footer>
Built using Sveltekit.
</footer>
<style class="flex">
footer {
@apply bg-primary-700;
@apply text-center text-white;
@apply p-4;
}
</style>

View file

@ -1,7 +0,0 @@
<script lang="ts">
export let id: string;
export let value: string;
export let disabled: boolean = false;
</script>
<textarea {id} bind:value {disabled}></textarea>

View file

@ -1,67 +0,0 @@
<script lang="ts">
export let isOpen = false;
export let isHidden = false;
function handleClick() {
isOpen = !isOpen;
}
</script>
<button
class="menu"
class:hidden={isHidden}
class:opened={isOpen}
on:click={handleClick}
aria-label="Main Menu"
aria-expanded={isOpen}
aria-hidden={isHidden}
>
<svg width="32" height="32" viewBox="0 0 100 100">
<path class="line line1" d="M 20,29.000046 H 80.000231 C 80.000231,29.000046 94.498839,28.817352 94.532987,66.711331 94.543142,77.980673 90.966081,81.670246 85.259173,81.668997 79.552261,81.667751 75.000211,74.999942 75.000211,74.999942 L 25.000021,25.000058" />
<path class="line line2" d="M 20,50 H 80" />
<path class="line line3" d="M 20,70.999954 H 80.000231 C 80.000231,70.999954 94.498839,71.182648 94.532987,33.288669 94.543142,22.019327 90.966081,18.329754 85.259173,18.331003 79.552261,18.332249 75.000211,25.000058 75.000211,25.000058 L 25.000021,74.999942" />
</svg>
</button>
<style>
.menu {
background-color: transparent;
border: none;
cursor: pointer;
}
.line {
fill: none;
stroke: white;
stroke-width: 6;
transition: stroke-dasharray 600ms cubic-bezier(0.4, 0, 0.2, 1),
stroke-dashoffset 600ms cubic-bezier(0.4, 0, 0.2, 1);
}
.line1 {
stroke-dasharray: 60 207;
stroke-width: 6;
}
.line2 {
stroke-dasharray: 60 60;
stroke-width: 6;
}
.line3 {
stroke-dasharray: 60 207;
stroke-width: 6;
}
.opened .line1 {
stroke-dasharray: 90 207;
stroke-dashoffset: -134;
stroke-width: 6;
}
.opened .line2 {
stroke-dasharray: 1 60;
stroke-dashoffset: -30;
stroke-width: 6;
}
.opened .line3 {
stroke-dasharray: 90 207;
stroke-dashoffset: -134;
stroke-width: 6;
}
</style>

View file

@ -2,102 +2,32 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { logout, storedUser } from '$lib/auth/session'; import { logout, storedUser } from '$lib/auth/session';
import type { StoredUser } from '$lib/auth/session'; import type { StoredUser } from '$lib/auth/session';
import Hamburger from './Hamburger.svelte';
import { slide } from 'svelte/transition';
export let isHidden = false; let user: StoredUser | null = null;
export let currentHeight: number;
let user: StoredUser | unknown | null = null;
storedUser.subscribe((value) => { storedUser.subscribe((value) => {
user = value; user = value;
}); });
let viewportWidth: number;
$: largeScreen = viewportWidth > 640;
let menuIsOpen = false;
function closeMenu() {
menuIsOpen = false;
}
function handleLogout() { function handleLogout() {
menuIsOpen = false;
logout(); logout();
goto('/'); goto('/');
} }
</script> </script>
<svelte:window bind:outerWidth={viewportWidth} /> <nav>
<ul>
<nav <li class="inline">
class="sticky top-0 transition-transform ease-linear z-40" <a href="/">Home</a>
class:navbarHidden={isHidden}
bind:clientHeight={currentHeight}
>
{#if !largeScreen}
<div id="navHead" class="flex" class:smallScreen={!largeScreen}>
<a href="/" on:click={closeMenu} class="group">
<img
src="/images/common/logo.svg"
alt="Dewit Logo"
class="w-11 h-11 p-2 transition-all bg-secondary-700 rounded-lg group-hover:p-0.5 group-hover:bg-secondary"
/>
</a>
<Hamburger bind:isOpen={menuIsOpen} />
</div>
{/if}
{#if menuIsOpen || largeScreen}
<div id="navContent"
transition:slide="{{delay: largeScreen ? 0 : 100, duration: largeScreen ? 0 : 500}}"
class:flex-col={!largeScreen}
class="flex gap-1 items-center"
>
{#if largeScreen}
<a href="/" class="group">
<img
src="/images/common/logo.svg"
alt="Dewit Logo"
class="w-11 h-11 p-2 transition-all bg-secondary-700 rounded-lg group-hover:p-0.5 group-hover:bg-secondary"
/>
</a>
{/if}
{#if user} {#if user}
<a href={`/users/${user.id}`} on:click={closeMenu}>Profile</a> <a href={`/users/${user.id}/todos`}>Todos</a>
<a href={`/users/${user.id}/todos`} on:click={closeMenu}>Todos</a> <a href={`/users/${user.id}`}>My Profile</a>
{#if user.isAdmin} {#if user.isAdmin}
<a href={`/users/all`} on:click={closeMenu}>All Users</a> <a href={`/users/all`}>All Users</a>
{/if} {/if}
{#if largeScreen}<div class="grow" aria-hidden />{/if} <button on:click={handleLogout}>Log Out</button>
<button class="outlineButton" on:click={handleLogout}>Log Out</button>
{:else} {:else}
{#if largeScreen}<div class="grow" aria-hidden />{/if} <a href="/login">Log In</a>
<a href="/login" on:click={closeMenu}>Log In</a>
{/if}
</div>
{/if} {/if}
</li>
</ul>
</nav> </nav>
<style>
nav {
@apply text-xl font-semibold;
@apply p-2;
@apply bg-primary;
}
a {
@apply block max-w-fit p-2;
@apply text-secondary text-center no-underline;
@apply drop-shadow-md;
@apply transition-all;
}
a:hover {
@apply text-accent font-bold;
}
.smallScreen {
@apply justify-between w-full;
}
.navbarHidden {
@apply -translate-y-full;
}
</style>

View file

@ -1,6 +0,0 @@
/**
* A generic string-to-string mapping.
*/
export type StringMapping = {
[key: string]: string;
}

View file

@ -5,47 +5,3 @@
export function range(n: number): number[] { export function range(n: number): number[] {
return Array.from(Array(n).keys()); return Array.from(Array(n).keys());
} }
/**
* Converts a given number of milliseconds into a human-readable string representation of time.
*
* @param {number} milliseconds - The number of milliseconds to be formatted.
* @param {number}
* @returns {string} The formatted time string in years, months, days, hours, minutes, and seconds.
*/
export function formatTime(milliseconds: number, precision: number = 3): string {
const seconds = Math.round(milliseconds / 1000);
if (seconds <= 0) {
return '0 seconds';
}
const years = Math.floor(seconds / (365 * 24 * 60 * 60));
const months = Math.floor((seconds % (365 * 24 * 60 * 60)) / (30 * 24 * 60 * 60));
const days = Math.floor((seconds % (30 * 24 * 60 * 60)) / (24 * 60 * 60));
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
const minutes = Math.floor((seconds % (60 * 60)) / 60);
const remainingSeconds = seconds % 60;
const timeParts = [];
if (years > 0) {
timeParts.push(`${years} year${years > 1 ? 's' : ''}`);
}
if (months > 0) {
timeParts.push(`${months} month${months > 1 ? 's' : ''}`);
}
if (days > 0) {
timeParts.push(`${days} day${days > 1 ? 's' : ''}`);
}
if (hours > 0) {
timeParts.push(`${hours} hour${hours > 1 ? 's' : ''}`);
}
if (minutes > 0) {
timeParts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`);
}
if (remainingSeconds > 0) {
timeParts.push(`${remainingSeconds} second${remainingSeconds > 1 ? 's' : ''}`);
}
return timeParts.slice(0, precision).join(', ');
}

View file

@ -27,7 +27,6 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { loadUserIntoStore } from '$lib/auth/session'; import { loadUserIntoStore } from '$lib/auth/session';
import Navbar from '$lib/components/navbar/Navbar.svelte'; import Navbar from '$lib/components/navbar/Navbar.svelte';
import Footer from '$lib/components/footer/Footer.svelte';
export let title = import.meta.env.PUBLIC_TITLE; export let title = import.meta.env.PUBLIC_TITLE;
export let description = import.meta.env.PUBLIC_DESCRIPTION; export let description = import.meta.env.PUBLIC_DESCRIPTION;
@ -35,40 +34,10 @@
const ogImageUrl = new URL('/images/common/og-image.webp', import.meta.url).href const ogImageUrl = new URL('/images/common/og-image.webp', import.meta.url).href
let navbarHidden = false;
let lastKnownScrollPosY = 0;
const navbarToggleThreshold = 32;
let navbarHeight: number;
let viewportWidth: number;
function handleScroll(event: Event): void {
if (viewportWidth >= 640 || window.scrollY <= navbarHeight) {
navbarHidden = false;
} else {
let scrollDistance = Math.abs(window.scrollY - lastKnownScrollPosY);
let scrollDirection = (window.scrollY - lastKnownScrollPosY) > 0 ? 'down' : 'up';
if (scrollDistance > navbarToggleThreshold) {
if (scrollDirection == 'down') {
navbarHidden = true;
} else {
navbarHidden = false;
}
}
}
lastKnownScrollPosY = window.scrollY;
}
onMount(async () => { onMount(async () => {
loadUserIntoStore(); loadUserIntoStore();
}); });
</script> </script>
<svelte:window bind:outerWidth={viewportWidth} on:scroll={handleScroll} /> <Navbar />
<slot />
<Navbar bind:isHidden={navbarHidden} bind:currentHeight={navbarHeight} />
<div class="flex flex-col grow justify-center items-center">
<slot />
</div>
<Footer />

View file

@ -2,25 +2,5 @@
console.log(import.meta.env.MODE) console.log(import.meta.env.MODE)
</script> </script>
<div id="container"> <h1>Hello World!</h1>
<div class="flex flex-col sm:flex-row items-center"> <p>This is a simple Todo-App as a tech stack template for new projects.</p>
<img src="images/common/logo.svg" alt="Dewit Logo" class="w-32 h-32" />
<h1 class="text-6xl sm:text-8xl font-accent font-semibold">Dewit</h1>
</div>
<p class="text-2xl sm:text-3xl">
This is a simple Todo-App as a tech stack template for new projects.
</p>
</div>
<style>
#container {
@apply flex flex-col gap-32 justify-center items-center grow;
@apply text-center;
}
p {
@apply p-2;
}
h1 {
@apply p-2;
}
</style>

View file

@ -2,8 +2,6 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { login } from '$lib/auth/session'; import { login } from '$lib/auth/session';
import { EndpointFactory } from '$lib/api/endpoints';
let email: string = ""; let email: string = "";
let password: string = ""; let password: string = "";
@ -39,12 +37,6 @@
formError = true; formError = true;
} }
} }
async function handleDebugButtonClick() {
let epf = new EndpointFactory();
let rue = epf.createReadUserEndpoint(54);
console.log(await rue.call());
}
</script> </script>
<div class="flex flex-col items-center gap-y-4"> <div class="flex flex-col items-center gap-y-4">
@ -66,11 +58,4 @@
{/if} {/if}
</fieldset> </fieldset>
</form> </form>
<button on:click={handleDebugButtonClick}>Click me</button>
</div> </div>
<style>
button {
@apply bg-accent;
}
</style>

View file

@ -1,77 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { TodoDetailPage } from './+page'; import type { TodoDetailPage } from './+page';
import type { TodoItem } from '$lib/api/types';
import { markdownToHtml } from '$lib/utils/markdown'; import { markdownToHtml } from '$lib/utils/markdown';
import Textarea from '$lib/components/input/Textarea.svelte';
export let data: TodoDetailPage; export let data: TodoDetailPage;
let todo: TodoItem = data.todo;
let editingEnabled = true;
function adjustTextareaRows(event: InputEvent) {
const textarea = event.target as HTMLTextAreaElement;
const scrollHeight = textarea.scrollHeight;
const computedStyle = getComputedStyle(textarea);
const lineHeight = parseFloat(computedStyle.lineHeight);
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
const contentHeight = scrollHeight - paddingTop - paddingBottom;
const rows = Math.ceil(contentHeight / lineHeight);
textarea.rows = rows
}
async function handleFormSubmit() {
}
</script> </script>
<div class="flex flex-col w-full h-full p-4 sm:p-8 gap-4 max-w-7xl"> <h1>{data.todo.title}</h1>
<form action="" method="post" on:submit={handleFormSubmit}> <div>
<fieldset>
<div class="field-pair">
<Textarea id="title" value={todo.title}/>
<label for="title">Title</label>
</div>
</fieldset>
<div class="text-display">
{@html markdownToHtml(data.todo.description)} {@html markdownToHtml(data.todo.description)}
</div>
</form>
</div> </div>
<style>
h1 {
@apply font-semibold;
@apply w-full;
}
span.title-heading {
@apply text-xl text-secondary font-bold;
}
.text-display {
@apply text-start;
@apply border-2 border-secondary rounded-md;
@apply w-full p-4;
}
div.field-pair {
@apply flex flex-col gap-0.5;
}
input {
@apply w-full;
}
input:disabled {
@apply bg-background border-none;
}
.field-pair label {
@apply border-t;
}
</style>

View file

@ -1,19 +1,18 @@
import { EndpointFactory } from '$lib/api/endpoints'; import type { PageLoad } from './$types';
import type { TodoItem } from '$lib/api/types'; import type { TodoItem } from '$lib/api/types';
import { readTodo } from '$lib/api/endpoints';
/** @type {import('./$types').PageLoad} */ export const ssr = false;
export async function load({ fetch, params }) { export const load = (async ({ params }) => {
// check if user exists
let endpointFactory = new EndpointFactory(fetch); const todoId = params.todo;
let readTodoEndpoint = endpointFactory.createReadTodoEndpoint(params.todo); const todo = await readTodo(todoId);
return { return {
endpointFactory: endpointFactory, todo: todo
todo: readTodoEndpoint.call(),
}; };
}
}) satisfies PageLoad;
export interface TodoDetailPage { export interface TodoDetailPage {
endpointFactory: EndpointFactory,
todo: TodoItem todo: TodoItem
} }

View file

@ -1,67 +1,8 @@
<script lang="ts"> <script lang="ts">
import { error } from '@sveltejs/kit';
import type { UserProfilePage } from "./+page"; import type { UserProfilePage } from "./+page";
import { getUserFromLocalstorage as getUser } from '$lib/auth/session';
import { formatTime } from '$lib/utils/utils';
import { onMount } from 'svelte';
export let data: UserProfilePage; export let data: UserProfilePage;
const loggedInUser = getUser();
if (loggedInUser === null) {
throw error(401, 'You must be logged in to view this page.');
}
const userCreatedDate = Date.parse(data.user.created);
let dateNow: number;
$: timeSinceCreation = formatTime(dateNow - userCreatedDate);
function updateCurrentTime() {
dateNow = Date.now()
setTimeout(updateCurrentTime, 1000);
}
onMount(async () => {
updateCurrentTime();
});
</script> </script>
<div class="grow flex flex-col items-center justify-center gap-y-4 px-4"> <h1>Profile page for {data.user.email}</h1>
{#if loggedInUser.id === data.user.id} <a href={`/users/${data.user.id}/todos`}>{data.user.first_name}'s Todos</a>
<h1>My Profile</h1>
{:else}
<h1>{`${data.user.first_name} ${data.user.last_name}'s Profile`}</h1>
{/if}
<div class="profileInfo">
<p class="description">First Name</p><p class="data">{data.user.first_name}</p>
<p class="description">Last Name</p><p class="data">{data.user.last_name}</p>
<p class="description">Email</p><p class="data">{data.user.email}</p>
<p class="description">Member Since</p><p class="data">{timeSinceCreation}</p>
</div>
<a class="button"
href={`/users/${data.user.id}/todos`}
>
View Todos
</a>
</div>
<style>
h1 {
@apply text-2xl;
}
div.profileInfo {
@apply grid grid-cols-3 gap-x-2 gap-y-4;
@apply p-4 rounded-lg;
@apply border-2 border-secondary/50;
}
p.description {
@apply text-secondary font-semibold text-end;
}
p.data{
@apply col-span-2;
}
</style>

View file

@ -1,19 +1,18 @@
import { EndpointFactory } from '$lib/api/endpoints'; import type { PageLoad } from './$types';
import type { User } from '$lib/api/types'; import type { User } from '$lib/api/types';
import { readUser } from '$lib/api/endpoints';
/** @type {import('./$types').PageLoad} */ export const ssr = false;
export async function load({ fetch, params }) { export const load = (async ({ params }) => {
// check if user exists
let endpointFactory = new EndpointFactory(fetch); const userId = params.user;
let readUserEndpoint = endpointFactory.createReadUserEndpoint(params.user); const user = await readUser(userId);
return { return {
endpointFactory: endpointFactory, user: user
user: readUserEndpoint.call()
}; };
}
}) satisfies PageLoad;
export interface UserProfilePage { export interface UserProfilePage {
endpointFactory: EndpointFactory,
user: User user: User
} }

View file

@ -1,57 +1,59 @@
<script lang="ts"> <script lang="ts">
import { error } from '@sveltejs/kit';
import Table from '$lib/components/data-table/Table.svelte' import Table from '$lib/components/data-table/Table.svelte'
import type { Column } from '$lib/components/data-table/types'; import type { Column } from '$lib/components/data-table/types';
import type { UserProfilePage } from "../+page"; import type { UserTodosPage } from "./+page";
import { getUserFromLocalstorage as getUser } from '$lib/auth/session'; import { readTodos, readTodoCount } from '$lib/api/endpoints';
export let data: UserProfilePage; export let data: UserTodosPage;
const loggedInUser = getUser();
if (loggedInUser === null) {
throw error(401, 'You must be logged in to view this page.');
}
const columns: Column[] = [ const columns: Column[] = [
{ {
field: 'id', field: "id",
heading: 'ID', heading: "ID",
type: 'number,'
}, },
{ {
field: 'title', field: "title",
heading: 'Title', heading: "Title",
isLink: true, isLink: true,
linkTarget: '/todos/%id%', linkTarget: '/todos/%id%',
}, },
{ {
field: 'done', field: "done",
heading: 'Done', heading: "Done",
type: 'boolean', type: "boolean",
}, },
{ {
field: 'created', field: "created",
heading: 'Created', heading: "Created",
type: 'date', type: "date",
}, },
{ {
field: 'updated', field: "updated",
heading: 'Updated', heading: "Updated",
type: 'date', type: "date",
}, },
{ {
field: 'finished', field: "finished",
heading: 'Finished', heading: "Finished",
type: 'date', type: "date",
}, },
]; ];
const table = { const table = {
caption: loggedInUser.id === data.user.id ? 'My TODOs' : `${data.user.first_name} ${data.user.last_name}'s TODOs`, caption: `List of TODOs for user ${data.user.first_name} ${data.user.last_name}`,
columns: loggedInUser.isAdmin ? columns : columns.slice(1, -1), columns: columns,
getItemsEndpoint: data.endpointFactory.createReadTodosEndpoint(data.user.id), getItemsEndpoint: {
getItemCountEndpoint: data.endpointFactory.createReadTodoCountEndpoint(data.user.id), callable: readTodos,
defaultSortField: 'updated', args: [
defaultSortOrder: 'desc', data.user.id
]
},
getItemCountEndpoint: {
callable: readTodoCount,
args: [
data.user.id
]
}
}; };
</script> </script>

View file

@ -0,0 +1,9 @@
import type { User } from '$lib/api/types';
import { load as defaultLoad } from '../+page';
export const ssr = false;
export const load = defaultLoad;
export interface UserTodosPage {
user: User
}

View file

@ -1,43 +1,40 @@
<script lang="ts"> <script lang="ts">
import Table from '$lib/components/data-table/Table.svelte'; import Table from '$lib/components/data-table/Table.svelte';
import type { Column } from '$lib/components/data-table/types'; import type { Column } from '$lib/components/data-table/types';
import type { UserListPage } from "./+page"; import { readUsers, readUserCount } from '$lib/api/endpoints';
export let data: UserListPage;
const columns: Column[] = [ const columns: Column[] = [
{ {
field: 'id', field: "id",
heading: 'ID', heading: "ID",
type: 'number',
}, },
{ {
field: 'email', field: "email",
heading: 'Email', heading: "Email",
isLink: true, isLink: true,
linkTarget: '/users/%id%', linkTarget: '/users/%id%',
}, },
{ {
field: 'first_name', field: "first_name",
heading: 'First Name', heading: "First Name",
}, },
{ {
field: 'last_name', field: "last_name",
heading: 'Last Name', heading: "Last Name",
}, },
{ {
field: 'created', field: "created",
heading: 'Created', heading: "Created",
type: 'date', type: 'date',
}, },
{ {
field: 'updated', field: "updated",
heading: 'Updated', heading: "Updated",
type: 'date', type: 'date',
}, },
{ {
field: 'is_admin', field: "is_admin",
heading: 'Admin', heading: "Admin",
type: 'boolean', type: 'boolean',
}, },
] ]
@ -45,10 +42,14 @@
const table = { const table = {
caption: "List of users", caption: "List of users",
columns: columns, columns: columns,
getItemsEndpoint: data.endpointFactory.createReadUsersEndpoint(), getItemsEndpoint: {
getItemCountEndpoint: data.endpointFactory.createReadUserCountEndpoint(), callable: readUsers,
defaultSortField: 'id', args: []
defaultSortOrder: 'asc', },
getItemCountEndpoint: {
callable: readUserCount,
args: []
}
}; };
</script> </script>

View file

@ -1,19 +0,0 @@
import { EndpointFactory } from '$lib/api/endpoints';
import type { User } from '$lib/api/types';
/** @type {import('./$types').PageLoad} */
export async function load({ fetch }) {
let endpointFactory = new EndpointFactory(fetch);
let readUsersEndpoint = endpointFactory.createReadUsersEndpoint();
return {
endpointFactory: endpointFactory,
users: readUsersEndpoint.call()
};
}
export interface UserListPage {
endpointFactory: EndpointFactory,
users: User[]
}

View file

@ -0,0 +1,25 @@
@use 'sass:color';
@use '@material/theme/color-palette';
// Svelte Colors!
@use '@material/theme/index' as theme with (
$primary: #ff3e00,
$secondary: #676778,
$surface: #fff,
$background: #fff,
$error: color-palette.$red-900
);
html,
body {
background-color: theme.$surface;
color: theme.$on-surface;
}
a {
color: #40b3ff;
}
a:visited {
color: color.scale(#40b3ff, $lightness: -35%);
}

View file

@ -0,0 +1,25 @@
@use 'sass:color';
@use '@material/theme/color-palette';
// Svelte Colors! (Dark Theme)
@use '@material/theme/index' as theme with (
$primary: #ff3e00,
$secondary: color.scale(#676778, $whiteness: -10%),
$surface: color.adjust(color-palette.$grey-900, $blue: +4),
$background: #000,
$error: color-palette.$red-700
);
html,
body {
background-color: #000;
color: theme.$on-surface;
}
a {
color: #40b3ff;
}
a:visited {
color: color.scale(#40b3ff, $lightness: -35%);
}

View file

@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
width="800px"
height="800px"
viewBox="0 0 24 24"
fill="none"
version="1.1"
id="svg174"
sodipodi:docname="logo.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs178">
<linearGradient
inkscape:collect="always"
id="linearGradient920">
<stop
style="stop-color:#ff5c00;stop-opacity:0.49663314;"
offset="0"
id="stop916" />
<stop
style="stop-color:#d28a00;stop-opacity:1;"
offset="0.5"
id="stop924" />
<stop
style="stop-color:#ffec00;stop-opacity:0.5;"
offset="1"
id="stop918" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient920"
id="linearGradient922"
x1="2"
y1="12.48534"
x2="21.727"
y2="12.48534"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(0.1365,-0.48534)" />
</defs>
<sodipodi:namedview
id="namedview176"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showgrid="false"
inkscape:zoom="1.02625"
inkscape:cx="400.97442"
inkscape:cy="400"
inkscape:window-width="1900"
inkscape:window-height="1004"
inkscape:window-x="10"
inkscape:window-y="38"
inkscape:window-maximized="1"
inkscape:current-layer="svg174" />
<path
id="Vector"
d="m 8.1365,12.00006 4.2426,4.2426 8.4844,-8.48532 m -17.727,4.24272 4.24264,4.2426 m 8.48526,-8.48532 -3.2279,3.25742"
stroke="#000000"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="fill:none;stroke:url(#linearGradient922)" />
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -14,60 +14,44 @@ export default {
transparent: "transparent", transparent: "transparent",
current: "currentColor", current: "currentColor",
primary: { primary: {
50: "#D5E8EC", 50: "#D1DAF0",
100: "#9CCAD3", 100: "#859DD6",
200: "#4B9AAA", 200: "#5778C7",
300: "#326671", 300: "#385AA8",
400: "#1F4047", 400: "#29417A",
500: "#0D1B1E", 500: "#1F315C",
DEFAULT: "#0D1B1E", DEFAULT: "#1F315C",
600: "#0C191C", 600: "#14213D",
700: "#070F11", 700: "#0F192E",
800: "#040809", 800: "#0A111F",
900: "#000000", 900: "#05080F",
}, },
secondary: { secondary: {
50: "#F3F7F5", 50: "#DBF5FA",
100: "#DBE6E2", 100: "#B7EBF5",
200: "#B6CDC5", 200: "#81DCEE",
300: "#9EBDB2", 300: "#4BCDE7",
400: "#86AC9F", 400: "#1DB8D7",
500: "#6C9A8B", 500: "#189AB4",
DEFAULT: "#6C9A8B", DEFAULT: "#189AB4",
600: "#5B8677", 600: "#147B90",
700: "#4A6D61", 700: "#0F5C6C",
800: "#3A554B", 800: "#0A3D48",
900: "#192420", 900: "#051E24",
}, },
accent: { accent: {
50: "#FFF9EB", 50: "#FED7DE",
100: "#FFEDC2", 100: "#FD9BAD",
200: "#FFE7AD", 200: "#FC738C",
300: "#FFDA85", 300: "#FB4B6B",
400: "#FFCE5C", 400: "#FB234B",
500: "#FFC233", 500: "#DC042C",
DEFAULT: "#FFC233", DEFAULT: "#DC042C",
600: "#FFB60A", 600: "#9A031E",
700: "#E09D00", 700: "#780218",
800: "#A37200", 800: "#500110",
900: "#523900", 900: "#280008",
}, },
background: {
50: "#FFFFFF",
100: "#FFFFFF",
200: "#FFFFFF",
300: "#FFFFFF",
400: "#FFFFFF",
500: "#FCFCFC",
DEFAULT: "#FCFCFC",
600: "#CCCCCC",
700: "#A3A3A3",
800: "#707070",
900: "#333333",
},
success: "#00CC00",
warning: "#EDC000",
failure: "#E40000",
}, },
screens: { screens: {
'3xl': '1920px', '3xl': '1920px',