Compare commits

...
Sign in to create a new pull request.

6 commits
main ... design

Author SHA1 Message Date
2a01ca43b2 squashme 2023-06-17 19:30:47 +02:00
5c0f557308 squashme 2023-06-14 23:26:01 +02:00
4e4036896e squashme 2023-06-04 13:04:27 +02:00
d9cd2bda60 squashme 2023-06-04 03:26:46 +01:00
b3d291c3ee feat(frontend): make data-table fully responsive 2023-06-02 13:08:40 +01:00
2882db284d feat(frontend): styling 2023-06-01 21:40:36 +01:00
33 changed files with 1339 additions and 551 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 from fastapi import APIRouter, Depends, HTTPException, Request
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,6 +13,9 @@ 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",
@ -48,9 +51,11 @@ 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,12 +5,13 @@ 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 ORIGIN=http://localhost:8000
# 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"

47
frontend/docs/theme/colorscheme.gpl vendored Normal file
View file

@ -0,0 +1,47 @@
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,6 +8,13 @@
/* 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;
} }
@ -32,11 +39,10 @@
} }
a { a {
@apply underline text-secondary-500; @apply underline text-primary-300;
} }
a:visited { a:visited {
@apply text-secondary-700; @apply underline text-primary-400;
} }
} }
@ -44,25 +50,82 @@
button { button {
@apply p-2; @apply p-2;
@apply rounded-md; @apply rounded-md;
@apply bg-neutral-100; @apply bg-accent;
@apply border-2 border-neutral-200; @apply text-primary-400 font-semibold;
@apply drop-shadow-md;
@apply transition-all ease-in-out; @apply transition-all ease-in-out;
} }
button:hover { button:hover {
@apply bg-neutral-200; @apply bg-accent-600;
@apply border-neutral-300; @apply drop-shadow-lg;
@apply text-primary font-bold;
} }
button:active { button:active {
@apply bg-neutral-300; @apply bg-accent-700;
@apply border-neutral-400; @apply drop-shadow-none;
@apply text-primary-600;
} }
button:focus { button:focus {
@apply ring-1 ring-neutral-400 ring-offset-1; @apply ring-2 ring-accent-600;
} }
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 {
@ -80,43 +143,65 @@
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;
} }
@media screen(sm) { td {
caption, th, td { @apply p-1;
@apply p-2 text-start;
} }
caption {
@apply p-1 text-start font-semibold text-xl;
} }
fieldset { fieldset {
@apply border border-secondary-300; @apply border border-primary/50;
@apply p-2; @apply rounded-lg;
@apply p-4;
} }
legend { legend {
@apply text-black/50; @apply text-primary/50 font-light;
} }
input { input {
@apply text-primary font-semibold;
@apply border border-secondary; @apply border border-secondary;
@apply bg-secondary-50; @apply bg-secondary/20;
@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-400; @apply outline-none border-none ring-2 ring-secondary;
} }
label { label {
@apply text-sm text-secondary; @apply text-sm text-secondary font-light;
}
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,182 +1,167 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { ItemCount, TodoItem, User } from './types'; import type { ItemCount, TodoItem, Token, User } from './types';
import { getResponseBodyOrError } from '$lib/api/utils'; import { Endpoint } from './types';
import { getTokenFromLocalstorage } from '$lib/auth/session'; import type { StringMapping } from '$lib/utils/types';
import { _getTokenFromLocalstorage } from '$lib/auth/session';
/** /**
* Retrieves the currently logged in user's JWT from localstorage, * A factory class for creating `Endpoint` instances to interact with the backend API.
* or throws an error if it is not present.
* *
* @throws {error} - If the current user is not logged in or if their JWT is not saved in localstorage. * This class provides methods to create specific `Endpoint` instances for different API operations,
* 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();
* ```
*/ */
function getTokenOrError(): string { export class EndpointFactory {
let token = getTokenFromLocalstorage();
if (token === null) { /** The function to use for making API requests. */
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}`;
return headers;
} }
/** /**
* Retrieves the user with the specified ID from the backend API. * 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.
* @param {string} jwt - The JWT appended as a bearer token to authorize the request. * @throws {error} If the user is not currently logged in.
* @throws{error} - If the request fails or is not permitted.
*/ */
export async function readUser(userId: number, jwt: string = getTokenOrError()): Promise<User> { createReadUserEndpoint(userId: number): Endpoint<User> {
const endpoint = `/api/users/${userId}`; return new Endpoint<User>(
const response = await fetch(endpoint, { `/api/users/${userId}`,
method: 'GET', 'GET',
headers: { this._getDefaultHeadersWithAuth(),
'Accept': 'application/json', this.fetchFunction
'Authorization': `Bearer ${jwt}`, );
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as User;
} }
/** /**
* Retrieves the list of all users from the backend API. * Creates an Endpoint which retrieves the list of all users from the backend API.
* *
* @param {string} jwt - The JWT appended as a bearer token to authorize the request. * @throws {error} If the user is not currently logged in.
* @throws{error} - If the request fails or is not permitted.
*/ */
export async function readUsers( createReadUsersEndpoint(): Endpoint<User[]> {
skip: number, return new Endpoint<User[]>(
limit: number, `/api/admin/users/`,
sortby: string, 'GET',
sortorder: string, this._getDefaultHeadersWithAuth(),
jwt: string = getTokenOrError(), this.fetchFunction
): Promise<User[]> { );
const urlParameters = new URLSearchParams({
skip: `${skip}`,
limit: `${limit}`,
sortby: `${sortby}`,
sortorder: `${sortorder}`,
});
const endpoint = `/api/admin/users/?${urlParameters}`;
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 User[];
} }
/** /**
* Retrieves the total user count from the backend API. * Creates an Endpoint which 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 user is not currently logged in.
* @throws{error} - If the request fails or is not permitted.
*/ */
export async function readUserCount(jwt: string = getTokenOrError()): Promise<number> { createReadUserCountEndpoint(): Endpoint<ItemCount> {
const endpoint = '/api/admin/users/total/'; return new Endpoint<ItemCount>(
const response = await fetch(endpoint, { `/api/admin/users/total/`,
method: 'GET', 'GET',
headers: { this._getDefaultHeadersWithAuth(),
'Accept': 'application/json', this.fetchFunction
'Authorization': `Bearer ${jwt}`, );
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
const itemCount = responseJson as ItemCount;
return itemCount.total;
} }
/** /**
* Retrieves the list of all todo-items for the user with the specified ID from the backend API. * Creates an Endwpoint which retrieves the list of all todo-items 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. * @param {number} userId - The ID of the user whose todo-items to retrieve.
* @throws{error} - If the request fails or is not permitted. * @throws {error} If the user is not currently logged in.
*/ */
export async function readTodos( createReadTodosEndpoint(userId: number): Endpoint<TodoItem[]> {
userId: number, return new Endpoint<TodoItem[]>(
skip: number, `/api/todos/user/${userId}`,
limit: number, 'GET',
sortby: string, this._getDefaultHeadersWithAuth(),
sortorder: string, this.fetchFunction
jwt: string = getTokenOrError(), );
): Promise<TodoItem[]> {
const urlParameters = new URLSearchParams({
skip: `${skip}`,
limit: `${limit}`,
sortby: `${sortby}`,
sortorder: `${sortorder}`,
});
const endpoint = `/api/todos/user/${userId}?${urlParameters}`;
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. * Creates an Endpoint which 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. * @param {number} userId - The ID of the user whose todo-items to retrieve.
* @throws{error} - If the request fails or is not permitted. * @throws {error} If the user is not currently logged in.
*/ */
export async function readTodoCount( createReadTodoCountEndpoint(userId: number): Endpoint<ItemCount> {
userId: number, return new Endpoint<ItemCount>(
jwt: string = getTokenOrError() `/api/todos/user/${userId}/total/`,
): Promise<number> { 'GET',
const endpoint = `/api/todos/user/${userId}/total/`; this._getDefaultHeadersWithAuth(),
const response = await fetch(endpoint, { this.fetchFunction
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. * 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.
*/
createReadTodoEndpoint(todoId: number): Endpoint<TodoItem> {
return new Endpoint<TodoItem>(
`/api/todos/${todoId}`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Creates an Endpoint which sends an API request containing the specified login credentials
* to the backend, and returns the JWT retrieved from the response on sucess.
* *
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws {error} - If the request fails or is not permitted. * @throws {error} - If the request fails or is not permitted.
*/ */
export async function readTodo( createLoginEndpoint(): Endpoint<Token> {
todoId: number, return new Endpoint<Token>(
jwt: string = getTokenOrError() `/api/auth/login/`,
): Promise<TodoItem> { 'POST',
const endpoint = `/api/todos/${todoId}`; this._getDefaultHeaders(),
const response = await fetch(endpoint, { this.fetchFunction
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,7 +1,23 @@
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,
@ -12,6 +28,9 @@ 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,
@ -21,3 +40,88 @@ 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 { readUser } from '$lib/api/endpoints'; import { EndpointFactory } 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.
*/ */
function getUserFromLocalstorage(): StoredUser | null { export 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,36 +67,7 @@ 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;
} }
/** /**
@ -125,23 +96,36 @@ 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> {
const token = await requestJwt(email, password); let endpointFactory = new EndpointFactory();
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) as Token; const parsedToken = jwt_decode(token.token) as Token;
const userId = parsedToken.sub; const userId = parsedToken.sub;
const user = await readUser(userId, token); _saveTokenToLocalstorage(token.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), sessionExpires: new Date(parsedToken.exp * 1000),
}); });
loadUserIntoStore(); loadUserIntoStore();
} }

View file

@ -3,8 +3,9 @@
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, Endpoint } from '$lib/components/data-table/types'; import type { Column } 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.
@ -19,16 +20,35 @@
* 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 = { export let getItemsEndpoint: Endpoint<any>;
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 = { export let getItemCountEndpoint: Endpoint<ItemCount>;
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 = {
@ -37,11 +57,11 @@
field: string, field: string,
} }
} }
let currentSortField: string = columns[0].field; let currentSortField: string = defaultSortField;
let currentSortOrder: 'asc' | 'desc' = 'asc'; let currentSortOrder: 'asc' | 'desc' = defaultSortOrder;
let currentState: 'finished' | 'loading' | 'error' = 'finished'; let currentState: 'finished' | 'loading' | 'error' = 'finished';
let errorMessages: string[] = []; let errorMessage: 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;
@ -122,27 +142,28 @@
async function updateTable() { async function updateTable() {
try { try {
currentState = 'loading'; currentState = 'loading';
totalItemCount = await getItemCountEndpoint.callable(
...getItemCountEndpoint.args, let itemCountResponse = await getItemCountEndpoint.call();
); totalItemCount = itemCountResponse.total;
currentItems = await getItemsEndpoint.callable(
...getItemsEndpoint.args, let itemsResponse = await getItemsEndpoint.call({
currentItemsOffset, queryParameters: {
currentItemsPerPage, skip: `${currentItemsOffset}`,
currentSortField, limit: `${currentItemsPerPage}`,
currentSortOrder sortby: `${currentSortField}`,
); sortorder: `${currentSortOrder}`,
}
});
currentItems = itemsResponse;
currentState = 'finished'; currentState = 'finished';
} catch (error: any) { } catch (error: any) {
if (!errorMessages.length) { if (typeof error.body.message === 'string') {
// Make sure we have an error message to show to the user errorMessage = error.body.message;
if (typeof error.message === 'string') {
errorMessages.push(error.message);
} else { } else {
errorMessages.push("An unknown error occurred."); errorMessage = "An unknown error occurred.";
} }
} console.log(error)
currentState = 'error'; currentState = 'error';
} }
} }
@ -155,14 +176,41 @@
} }
onMount(async () => { onMount(async () => {
checkForTableResize();
await updateTable(); await updateTable();
checkForTableResize();
}); });
</script> </script>
<table> <svelte:window bind:outerWidth={viewportWidth} on:resize={checkForTableResize} />
<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}
@ -176,16 +224,22 @@
{/each} {/each}
</tr> </tr>
</thead> </thead>
<tbody> {/if}
<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>
@ -195,33 +249,67 @@
<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-red-500">{message}</td> <td colspan={columns.length} class="text-center text-failure font-bold">{errorMessage}</td>
</tr> </tr>
{/each}
{/if} {/if}
</tbody> </tbody>
</table> </table>
<button on:click={handleGotoFirstPage} disabled={gotoFirstPageDisabled}> <div class="table-controls">
<Icon src={ChevronDoubleLeft} mini class="w-6 h-6" /> <label for="itemsPerPageSelector" class="text-lg">Items per page</label>
</button> <select id="itemsPerPageSelector" bind:value={selectedItemsPerPage} on:change={handleItemsPerPageChange} class="mr-2">
<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,30 +1,76 @@
<script lang="ts"> <script lang="ts">
import { Icon, Check, XMark } from 'svelte-hero-icons'; import { Icon, Check, Minus, XMark } from 'svelte-hero-icons';
export let fieldName: string;
export let data: any; export let data: any;
export let type: 'string' | 'date' | 'boolean' = 'string'; export let type: 'string' | 'date' | 'number' | '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
{#if type === 'date'}
{#if data !== null && data !== undefined} class:compact-view={compactView}
{new Date(data).toLocaleDateString()} class:extended-view={extendedView}
{:else}
<!-- date undefined --> 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}
{#if data === null || data === undefined}
<Icon src={Minus} mini class="field-data w-6 h-6 text-primary/40" />
{:else}
{#if type === 'date'}
<p class="field-data">{new Date(data).toLocaleDateString()}</p>
{:else if type === 'boolean'} {:else if type === 'boolean'}
{#if data} {#if data}
<Icon src={Check} mini class="w-6 h-6" /> <Icon src={Check} mini class="field-data w-6 h-6 text-success/50" />
{:else} {:else}
<Icon src={XMark} mini class="w-6 h-6" /> <Icon src={XMark} mini class="field-data w-6 h-6 text-failure/50" />
{/if} {/if}
{:else} {:else}
{#if isLink} {#if isLink}
<a href={linkTarget}>{data}</a> <a href={linkTarget} class="field-data">{data}</a>
{:else} {:else}
{data} <p class="field-data">{data}</p>
{/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,8 +6,3 @@ export type Column = {
isLink?: boolean, isLink?: boolean,
linkTarget?: string, linkTarget?: string,
} }
export type Endpoint = {
callable: Function,
args: any[],
}

View file

@ -0,0 +1,14 @@
<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

@ -0,0 +1,7 @@
<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

@ -0,0 +1,67 @@
<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,32 +2,102 @@
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';
let user: StoredUser | null = null; export let isHidden = false;
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>
<nav> <svelte:window bind:outerWidth={viewportWidth} />
<ul>
<li class="inline"> <nav
<a href="/">Home</a> class="sticky top-0 transition-transform ease-linear z-40"
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}/todos`}>Todos</a> <a href={`/users/${user.id}`} on:click={closeMenu}>Profile</a>
<a href={`/users/${user.id}`}>My Profile</a> <a href={`/users/${user.id}/todos`} on:click={closeMenu}>Todos</a>
{#if user.isAdmin} {#if user.isAdmin}
<a href={`/users/all`}>All Users</a> <a href={`/users/all`} on:click={closeMenu}>All Users</a>
{/if} {/if}
<button on:click={handleLogout}>Log Out</button> {#if largeScreen}<div class="grow" aria-hidden />{/if}
<button class="outlineButton" on:click={handleLogout}>Log Out</button>
{:else} {:else}
<a href="/login">Log In</a> {#if largeScreen}<div class="grow" aria-hidden />{/if}
<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

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

View file

@ -5,3 +5,47 @@
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,6 +27,7 @@
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;
@ -34,10 +35,40 @@
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>
<Navbar /> <svelte:window bind:outerWidth={viewportWidth} on:scroll={handleScroll} />
<Navbar bind:isHidden={navbarHidden} bind:currentHeight={navbarHeight} />
<div class="flex flex-col grow justify-center items-center">
<slot /> <slot />
</div>
<Footer />

View file

@ -2,5 +2,25 @@
console.log(import.meta.env.MODE) console.log(import.meta.env.MODE)
</script> </script>
<h1>Hello World!</h1> <div id="container">
<p>This is a simple Todo-App as a tech stack template for new projects.</p> <div class="flex flex-col sm:flex-row items-center">
<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,6 +2,8 @@
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 = "";
@ -37,6 +39,12 @@
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">
@ -58,4 +66,11 @@
{/if} {/if}
</fieldset> </fieldset>
</form> </form>
<button on:click={handleDebugButtonClick}>Click me</button>
</div> </div>
<style>
button {
@apply bg-accent;
}
</style>

View file

@ -1,11 +1,77 @@
<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>
<h1>{data.todo.title}</h1> <div class="flex flex-col w-full h-full p-4 sm:p-8 gap-4 max-w-7xl">
<div> <form action="" method="post" on:submit={handleFormSubmit}>
<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> </div>
</form>
</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,18 +1,19 @@
import type { PageLoad } from './$types'; import { EndpointFactory } from '$lib/api/endpoints';
import type { TodoItem } from '$lib/api/types'; import type { TodoItem } from '$lib/api/types';
import { readTodo } from '$lib/api/endpoints';
export const ssr = false; /** @type {import('./$types').PageLoad} */
export const load = (async ({ params }) => { export async function load({ fetch, params }) {
// check if user exists
const todoId = params.todo; let endpointFactory = new EndpointFactory(fetch);
const todo = await readTodo(todoId); let readTodoEndpoint = endpointFactory.createReadTodoEndpoint(params.todo);
return { return {
todo: todo endpointFactory: endpointFactory,
todo: readTodoEndpoint.call(),
}; };
}
}) satisfies PageLoad;
export interface TodoDetailPage { export interface TodoDetailPage {
endpointFactory: EndpointFactory,
todo: TodoItem todo: TodoItem
} }

View file

@ -1,8 +1,67 @@
<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>
<h1>Profile page for {data.user.email}</h1> <div class="grow flex flex-col items-center justify-center gap-y-4 px-4">
<a href={`/users/${data.user.id}/todos`}>{data.user.first_name}'s Todos</a> {#if loggedInUser.id === data.user.id}
<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,18 +1,19 @@
import type { PageLoad } from './$types'; import { EndpointFactory } from '$lib/api/endpoints';
import type { User } from '$lib/api/types'; import type { User } from '$lib/api/types';
import { readUser } from '$lib/api/endpoints';
export const ssr = false; /** @type {import('./$types').PageLoad} */
export const load = (async ({ params }) => { export async function load({ fetch, params }) {
// check if user exists
const userId = params.user; let endpointFactory = new EndpointFactory(fetch);
const user = await readUser(userId); let readUserEndpoint = endpointFactory.createReadUserEndpoint(params.user);
return { return {
user: user endpointFactory: endpointFactory,
user: readUserEndpoint.call()
}; };
}
}) satisfies PageLoad;
export interface UserProfilePage { export interface UserProfilePage {
endpointFactory: EndpointFactory,
user: User user: User
} }

View file

@ -1,59 +1,57 @@
<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 { UserTodosPage } from "./+page"; import type { UserProfilePage } from "../+page";
import { readTodos, readTodoCount } from '$lib/api/endpoints'; import { getUserFromLocalstorage as getUser } from '$lib/auth/session';
export let data: UserTodosPage; export let data: UserProfilePage;
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: `List of TODOs for user ${data.user.first_name} ${data.user.last_name}`, caption: loggedInUser.id === data.user.id ? 'My TODOs' : `${data.user.first_name} ${data.user.last_name}'s TODOs`,
columns: columns, columns: loggedInUser.isAdmin ? columns : columns.slice(1, -1),
getItemsEndpoint: { getItemsEndpoint: data.endpointFactory.createReadTodosEndpoint(data.user.id),
callable: readTodos, getItemCountEndpoint: data.endpointFactory.createReadTodoCountEndpoint(data.user.id),
args: [ defaultSortField: 'updated',
data.user.id defaultSortOrder: 'desc',
]
},
getItemCountEndpoint: {
callable: readTodoCount,
args: [
data.user.id
]
}
}; };
</script> </script>

View file

@ -1,9 +0,0 @@
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,40 +1,43 @@
<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 { readUsers, readUserCount } from '$lib/api/endpoints'; import type { UserListPage } from "./+page";
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',
}, },
] ]
@ -42,14 +45,10 @@
const table = { const table = {
caption: "List of users", caption: "List of users",
columns: columns, columns: columns,
getItemsEndpoint: { getItemsEndpoint: data.endpointFactory.createReadUsersEndpoint(),
callable: readUsers, getItemCountEndpoint: data.endpointFactory.createReadUserCountEndpoint(),
args: [] defaultSortField: 'id',
}, defaultSortOrder: 'asc',
getItemCountEndpoint: {
callable: readUserCount,
args: []
}
}; };
</script> </script>

View file

@ -0,0 +1,19 @@
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

@ -1,25 +0,0 @@
@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

@ -1,25 +0,0 @@
@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

@ -0,0 +1,74 @@
<?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>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

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