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)):
"""Returns a JWT for the user whose credentials were provided.

View file

@ -2,7 +2,7 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
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
import todo.auth.auth as auth
import logging
logger = logging.getLogger()
router = APIRouter(
prefix="/users",
@ -48,9 +51,11 @@ def create_user(
@router.get("/{user_id}", response_model=userschema.User)
def read_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
logger.error(request)
try:
user = usercrud.read_user(db=db, id=user_id)
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_AUTHOR="John Doe"
# These set how node serves the sveltekit app
HOST="0.0.0.0"
PORT="3000"
# 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`.
ORIGIN=http://localhost
#ORIGIN=http://localhost
PROTOCOL_HEADER="x-forwarded-proto"
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`.
ORIGIN=http://localhost
ORIGIN=http://localhost:8000
# See `https://kit.svelte.dev/docs/adapter-node#environment-variables-address-header-and-xff-depth`.
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 */
@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 {
@apply font-bold text-primary;
}
@ -32,11 +39,10 @@
}
a {
@apply underline text-secondary-500;
@apply underline text-primary-300;
}
a:visited {
@apply text-secondary-700;
@apply underline text-primary-400;
}
}
@ -44,25 +50,82 @@
button {
@apply p-2;
@apply rounded-md;
@apply bg-neutral-100;
@apply border-2 border-neutral-200;
@apply bg-accent;
@apply text-primary-400 font-semibold;
@apply drop-shadow-md;
@apply transition-all ease-in-out;
}
button:hover {
@apply bg-neutral-200;
@apply border-neutral-300;
@apply bg-accent-600;
@apply drop-shadow-lg;
@apply text-primary font-bold;
}
button:active {
@apply bg-neutral-300;
@apply border-neutral-400;
@apply bg-accent-700;
@apply drop-shadow-none;
@apply text-primary-600;
}
button:focus {
@apply ring-1 ring-neutral-400 ring-offset-1;
@apply ring-2 ring-accent-600;
}
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 bg-neutral-100;
@apply border-2 border-neutral-200;
}
table {
@ -80,43 +143,65 @@
tr:last-of-type td:last-of-type {
@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 {
@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 {
@apply bg-primary font-semibold text-secondary-100;
}
caption, th, td {
@apply p-1;
}
@media screen(sm) {
caption, th, td {
@apply p-2 text-start;
}
td {
@apply p-1;
}
caption {
@apply p-1 text-start font-semibold text-xl;
}
fieldset {
@apply border border-secondary-300;
@apply p-2;
@apply border border-primary/50;
@apply rounded-lg;
@apply p-4;
}
legend {
@apply text-black/50;
@apply text-primary/50 font-light;
}
input {
@apply text-primary font-semibold;
@apply border border-secondary;
@apply bg-secondary-50;
@apply bg-secondary/20;
@apply rounded-md;
@apply p-1;
}
input::placeholder {
@apply text-secondary/50 font-light;
}
input:focus {
@apply outline-none border-none ring-2 ring-secondary-400;
@apply outline-none border-none ring-2 ring-secondary;
}
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 type { ItemCount, TodoItem, User } from './types';
import { getResponseBodyOrError } from '$lib/api/utils';
import { getTokenFromLocalstorage } from '$lib/auth/session';
import type { ItemCount, TodoItem, Token, User } from './types';
import { Endpoint } from './types';
import type { StringMapping } from '$lib/utils/types';
import { _getTokenFromLocalstorage } from '$lib/auth/session';
/**
* Retrieves the currently logged in user's JWT from localstorage,
* 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.
*/
function getTokenOrError(): string {
let token = getTokenFromLocalstorage();
if (token === null) {
throw error(401, 'You are not logged in.');
* A factory class for creating `Endpoint` instances to interact with the backend API.
*
* 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();
* ```
*/
export class EndpointFactory {
/** 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();
}
return token;
}
/**
* Retrieves the user with the specified ID from the backend API.
*
* @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 request fails or is not permitted.
*/
export async function readUser(userId: number, jwt: string = getTokenOrError()): Promise<User> {
const endpoint = `/api/users/${userId}`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
private _getDefaultHeaders(): StringMapping {
return {
'Accept': 'application/json',
'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.
*
* @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 readUsers(
skip: number,
limit: number,
sortby: string,
sortorder: string,
jwt: string = getTokenOrError(),
): 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',
private _getDefaultHeadersWithAuth(): StringMapping {
if (this._jwt === null) {
throw error(401, 'You are not logged in.');
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as User[];
}
/**
* 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.
*/
export async function readUserCount(jwt: string = getTokenOrError()): Promise<number> {
const endpoint = '/api/admin/users/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 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.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readTodos(
userId: number,
skip: number,
limit: number,
sortby: string,
sortorder: string,
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.
*
* @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;
let headers = this._getDefaultHeaders();
headers['Authorization'] = `Bearer: ${this._jwt}`;
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.
* @throws {error} If the user is not currently logged in.
*/
createReadUserEndpoint(userId: number): Endpoint<User> {
return new Endpoint<User>(
`/api/users/${userId}`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Creates an Endpoint which retrieves the list of all users from the backend API.
*
* @throws {error} If the user is not currently logged in.
*/
createReadUsersEndpoint(): Endpoint<User[]> {
return new Endpoint<User[]>(
`/api/admin/users/`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Creates an Endpoint which retrieves the total user count from the backend API.
*
* @throws {error} If the user is not currently logged in.
*/
createReadUserCountEndpoint(): Endpoint<ItemCount> {
return new Endpoint<ItemCount>(
`/api/admin/users/total/`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Creates an Endwpoint which retrieves the list of all todo-items for the user with the specified ID from the backend API.
*
* @param {number} userId - The ID of the user whose todo-items to retrieve.
* @throws {error} If the user is not currently logged in.
*/
createReadTodosEndpoint(userId: number): Endpoint<TodoItem[]> {
return new Endpoint<TodoItem[]>(
`/api/todos/user/${userId}`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* Creates an Endpoint which retrieves the total todo-item count for the user with the specified ID from the backend API.
*
* @param {number} userId - The ID of the user whose todo-items to retrieve.
* @throws {error} If the user is not currently logged in.
*/
createReadTodoCountEndpoint(userId: number): Endpoint<ItemCount> {
return new Endpoint<ItemCount>(
`/api/todos/user/${userId}/total/`,
'GET',
this._getDefaultHeadersWithAuth(),
this.fetchFunction
);
}
/**
* 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.
*
* @throws {error} - If the request fails or is not permitted.
*/
createLoginEndpoint(): Endpoint<Token> {
return new Endpoint<Token>(
`/api/auth/login/`,
'POST',
this._getDefaultHeaders(),
this.fetchFunction
);
}
}

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 = {
total: number
}
/**
* An API response indicating a token.
*/
export type Token = {
token: string
}
/**
* An API response representing a User object.
*/
export type User = {
id: number,
email: string,
@ -12,6 +28,9 @@ export type User = {
is_admin: boolean,
}
/**
* An API response representing a TodoItem object.
*/
export type TodoItem = {
id: number,
title: string,
@ -21,3 +40,88 @@ export type TodoItem = {
updated: 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 { readUser } from '$lib/api/endpoints';
import { EndpointFactory } from '$lib/api/endpoints';
import jwt_decode from 'jwt-decode';
import { writable } from 'svelte/store';
@ -9,10 +9,10 @@ import { writable } from 'svelte/store';
export const storedUser = writable();
// 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
const userKey = 'user';
const _userKey = 'user';
export type StoredUser = {
id: number,
email: string,
@ -25,22 +25,22 @@ export type StoredUser = {
*
* @param {string} token - The token to save in localstorage.
*/
function saveTokenToLocalstorage(token: string): void {
localStorage.setItem(jwtKey, token);
function _saveTokenToLocalstorage(token: string): void {
localStorage.setItem(_jwtKey, token);
}
/**
* Retrieves and returns the token, if present, from localstorage.
*/
export function getTokenFromLocalstorage(): string | null {
return localStorage.getItem(jwtKey);
export function _getTokenFromLocalstorage(): string | null {
return localStorage.getItem(_jwtKey);
}
/**
* Removes the saved token from localstorage.
*/
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.
*/
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.
*/
function getUserFromLocalstorage(): StoredUser | null {
let item: string | null = localStorage.getItem(userKey);
export function getUserFromLocalstorage(): StoredUser | null {
let item: string | null = localStorage.getItem(_userKey);
if (typeof item !== 'string') {
return null;
}
@ -67,36 +67,7 @@ function getUserFromLocalstorage(): StoredUser | null {
* Removes the saved user from localstorage.
*/
function clearUserInLocalstorage(): void {
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;
localStorage.removeItem(_userKey);
}
/**
@ -125,23 +96,36 @@ function clearUserFromStore(): void {
* @throws {Error} - If any API request fails or the supplied credentials were invalid.
*/
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 {
sub: number,
exp: number
}
const parsedToken = jwt_decode(token) as Token;
const parsedToken = jwt_decode(token.token) as Token;
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({
id: user.id,
email: user.email,
isAdmin: user.is_admin,
sessionExpires: new Date(parsedToken.exp),
sessionExpires: new Date(parsedToken.exp * 1000),
});
loadUserIntoStore();
}

View file

@ -3,8 +3,9 @@
import { Icon, ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft } from 'svelte-hero-icons';
import Th from '$lib/components/data-table/Th.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 type { Endpoint, ItemCount } from '$lib/api/types';
/**
* The caption for the data table.
@ -19,16 +20,35 @@
* The function which fetches rows for the data table.
* Must support pagination and sorting.
*/
export let getItemsEndpoint: Endpoint = {
callable: () => [],
args: [],
}
export let getItemsEndpoint: Endpoint<any>;
/**
* The function which fetches the total number of items. Used for pagination.
*/
export let getItemCountEndpoint: Endpoint = {
callable: () => 0,
args: [],
export let getItemCountEndpoint: Endpoint<ItemCount>;
/**
* 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 = {
@ -37,11 +57,11 @@
field: string,
}
}
let currentSortField: string = columns[0].field;
let currentSortOrder: 'asc' | 'desc' = 'asc';
let currentSortField: string = defaultSortField;
let currentSortOrder: 'asc' | 'desc' = defaultSortOrder;
let currentState: 'finished' | 'loading' | 'error' = 'finished';
let errorMessages: string[] = [];
let errorMessage: string;
let currentItems: any[] = []; // TODO apply generics here
let totalItemCount: number | null = null;
@ -122,27 +142,28 @@
async function updateTable() {
try {
currentState = 'loading';
totalItemCount = await getItemCountEndpoint.callable(
...getItemCountEndpoint.args,
);
currentItems = await getItemsEndpoint.callable(
...getItemsEndpoint.args,
currentItemsOffset,
currentItemsPerPage,
currentSortField,
currentSortOrder
);
let itemCountResponse = await getItemCountEndpoint.call();
totalItemCount = itemCountResponse.total;
let itemsResponse = await getItemsEndpoint.call({
queryParameters: {
skip: `${currentItemsOffset}`,
limit: `${currentItemsPerPage}`,
sortby: `${currentSortField}`,
sortorder: `${currentSortOrder}`,
}
});
currentItems = itemsResponse;
currentState = 'finished';
} catch (error: any) {
if (!errorMessages.length) {
// Make sure we have an error message to show to the user
if (typeof error.message === 'string') {
errorMessages.push(error.message);
} else {
errorMessages.push("An unknown error occurred.");
}
if (typeof error.body.message === 'string') {
errorMessage = error.body.message;
} else {
errorMessage = "An unknown error occurred.";
}
console.log(error)
currentState = 'error';
}
}
@ -155,73 +176,140 @@
}
onMount(async () => {
checkForTableResize();
await updateTable();
checkForTableResize();
});
</script>
<table>
{#if caption !== null}
<caption>{caption}</caption>
{/if}
<thead>
<tr>
<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}
<caption>{caption}</caption>
{/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}
<Th
on:sortEvent={handleSortEvent}
field={column.field}
heading={column.heading}
order={currentSortField === column.field ? currentSortOrder : null}
sortable={column.sortable ?? true}
/>
<option selected={column.field === currentSortField} value={column.field}>{column.heading}</option>
{/each}
</tr>
</thead>
<tbody>
{#if currentState === 'finished'}
{#each currentItems as item}
</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>
<tr>
{#each columns as column}
<Td
data={item[column.field]}
type={column.type ?? 'string'}
isLink={column.isLink ?? false}
linkTarget={i(column.linkTarget, item) ?? ''}
<Th
on:sortEvent={handleSortEvent}
field={column.field}
heading={column.heading}
order={currentSortField === column.field ? currentSortOrder : null}
sortable={column.sortable ?? true}
/>
{/each}
</tr>
{/each}
{:else if currentState === 'loading'}
<tr>
<td colspan={columns.length}>Loading...</td>
</tr>
{:else if currentState === 'error'}
{#each errorMessages as message}
<tr>
<td colspan={columns.length} class="text-center text-red-500">{message}</td>
</tr>
{/each}
</thead>
{/if}
</tbody>
</table>
<button on:click={handleGotoFirstPage} disabled={gotoFirstPageDisabled}>
<Icon src={ChevronDoubleLeft} mini class="w-6 h-6" />
</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}
<option selected={option === currentItemsPerPage} value={option}>{option}</option>
{/each}
</select>
<tbody
class:compact-view={compactView}
class:extended-view={!compactView}
>
{#if currentState === 'finished'}
{#each currentItems as item}
<tr>
{#each columns as column}
<Td
fieldName={column.heading}
data={item[column.field]}
type={column.type ?? 'string'}
isLink={column.isLink ?? false}
linkTarget={i(column.linkTarget, item) ?? ''}
compactView={compactView}
/>
{/each}
</tr>
{/each}
{:else if currentState === 'loading'}
<tr>
<td colspan={columns.length}>Loading...</td>
</tr>
{:else if currentState === 'error'}
<tr>
<td colspan={columns.length} class="text-center text-failure font-bold">{errorMessage}</td>
</tr>
{/if}
</tbody>
</table>
<div class="table-controls">
<label for="itemsPerPageSelector" class="text-lg">Items per page</label>
<select id="itemsPerPageSelector" bind:value={selectedItemsPerPage} on:change={handleItemsPerPageChange} class="mr-2">
{#each itemsPerPageOptions as option}
<option selected={option === currentItemsPerPage} value={option}>{option}</option>
{/each}
</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">
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 type: 'string' | 'date' | 'boolean' = 'string';
export let type: 'string' | 'date' | 'number' | 'boolean' = 'string';
export let isLink: boolean = false;
export let linkTarget: string = "";
export let compactView = false;
$: extendedView = !compactView;
</script>
<td>
{#if type === 'date'}
{#if data !== null && data !== undefined}
{new Date(data).toLocaleDateString()}
{:else}
<!-- date undefined -->
{/if}
{:else if type === 'boolean'}
{#if data}
<Icon src={Check} mini class="w-6 h-6" />
{:else}
<Icon src={XMark} mini class="w-6 h-6" />
{/if}
<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 isLink}
<a href={linkTarget}>{data}</a>
{#if type === 'date'}
<p class="field-data">{new Date(data).toLocaleDateString()}</p>
{:else if type === 'boolean'}
{#if data}
<Icon src={Check} mini class="field-data w-6 h-6 text-success/50" />
{:else}
<Icon src={XMark} mini class="field-data w-6 h-6 text-failure/50" />
{/if}
{:else}
{data}
{#if isLink}
<a href={linkTarget} class="field-data">{data}</a>
{:else}
<p class="field-data">{data}</p>
{/if}
{/if}
{/if}
</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,
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 { logout, 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) => {
user = value;
});
let viewportWidth: number;
$: largeScreen = viewportWidth > 640;
let menuIsOpen = false;
function closeMenu() {
menuIsOpen = false;
}
function handleLogout() {
menuIsOpen = false;
logout();
goto('/');
}
</script>
<nav>
<ul>
<li class="inline">
<a href="/">Home</a>
{#if user}
<a href={`/users/${user.id}/todos`}>Todos</a>
<a href={`/users/${user.id}`}>My Profile</a>
{#if user.isAdmin}
<a href={`/users/all`}>All Users</a>
<svelte:window bind:outerWidth={viewportWidth} />
<nav
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}
<button on:click={handleLogout}>Log Out</button>
{:else}
<a href="/login">Log In</a>
{/if}
</li>
</ul>
{#if user}
<a href={`/users/${user.id}`} on:click={closeMenu}>Profile</a>
<a href={`/users/${user.id}/todos`} on:click={closeMenu}>Todos</a>
{#if user.isAdmin}
<a href={`/users/all`} on:click={closeMenu}>All Users</a>
{/if}
{#if largeScreen}<div class="grow" aria-hidden />{/if}
<button class="outlineButton" on:click={handleLogout}>Log Out</button>
{:else}
{#if largeScreen}<div class="grow" aria-hidden />{/if}
<a href="/login" on:click={closeMenu}>Log In</a>
{/if}
</div>
{/if}
</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[] {
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 { loadUserIntoStore } from '$lib/auth/session';
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 description = import.meta.env.PUBLIC_DESCRIPTION;
@ -34,10 +35,40 @@
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 () => {
loadUserIntoStore();
});
</script>
<Navbar />
<slot />
<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 />
</div>
<Footer />

View file

@ -2,5 +2,25 @@
console.log(import.meta.env.MODE)
</script>
<h1>Hello World!</h1>
<p>This is a simple Todo-App as a tech stack template for new projects.</p>
<div id="container">
<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 { login } from '$lib/auth/session';
import { EndpointFactory } from '$lib/api/endpoints';
let email: string = "";
let password: string = "";
@ -37,6 +39,12 @@
formError = true;
}
}
async function handleDebugButtonClick() {
let epf = new EndpointFactory();
let rue = epf.createReadUserEndpoint(54);
console.log(await rue.call());
}
</script>
<div class="flex flex-col items-center gap-y-4">
@ -58,4 +66,11 @@
{/if}
</fieldset>
</form>
<button on:click={handleDebugButtonClick}>Click me</button>
</div>
<style>
button {
@apply bg-accent;
}
</style>

View file

@ -1,11 +1,77 @@
<script lang="ts">
import type { TodoDetailPage } from './+page';
import type { TodoItem } from '$lib/api/types';
import { markdownToHtml } from '$lib/utils/markdown';
import Textarea from '$lib/components/input/Textarea.svelte';
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>
<h1>{data.todo.title}</h1>
<div>
{@html markdownToHtml(data.todo.description)}
<div class="flex flex-col w-full h-full p-4 sm:p-8 gap-4 max-w-7xl">
<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)}
</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 { readTodo } from '$lib/api/endpoints';
export const ssr = false;
export const load = (async ({ params }) => {
// check if user exists
const todoId = params.todo;
const todo = await readTodo(todoId);
/** @type {import('./$types').PageLoad} */
export async function load({ fetch, params }) {
let endpointFactory = new EndpointFactory(fetch);
let readTodoEndpoint = endpointFactory.createReadTodoEndpoint(params.todo);
return {
todo: todo
endpointFactory: endpointFactory,
todo: readTodoEndpoint.call(),
};
}) satisfies PageLoad;
}
export interface TodoDetailPage {
endpointFactory: EndpointFactory,
todo: TodoItem
}

View file

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

View file

@ -1,59 +1,57 @@
<script lang="ts">
import { error } from '@sveltejs/kit';
import Table from '$lib/components/data-table/Table.svelte'
import type { Column } from '$lib/components/data-table/types';
import type { UserTodosPage } from "./+page";
import { readTodos, readTodoCount } from '$lib/api/endpoints';
import type { UserProfilePage } from "../+page";
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[] = [
{
field: "id",
heading: "ID",
field: 'id',
heading: 'ID',
type: 'number,'
},
{
field: "title",
heading: "Title",
field: 'title',
heading: 'Title',
isLink: true,
linkTarget: '/todos/%id%',
},
{
field: "done",
heading: "Done",
type: "boolean",
field: 'done',
heading: 'Done',
type: 'boolean',
},
{
field: "created",
heading: "Created",
type: "date",
field: 'created',
heading: 'Created',
type: 'date',
},
{
field: "updated",
heading: "Updated",
type: "date",
field: 'updated',
heading: 'Updated',
type: 'date',
},
{
field: "finished",
heading: "Finished",
type: "date",
field: 'finished',
heading: 'Finished',
type: 'date',
},
];
const table = {
caption: `List of TODOs for user ${data.user.first_name} ${data.user.last_name}`,
columns: columns,
getItemsEndpoint: {
callable: readTodos,
args: [
data.user.id
]
},
getItemCountEndpoint: {
callable: readTodoCount,
args: [
data.user.id
]
}
caption: loggedInUser.id === data.user.id ? 'My TODOs' : `${data.user.first_name} ${data.user.last_name}'s TODOs`,
columns: loggedInUser.isAdmin ? columns : columns.slice(1, -1),
getItemsEndpoint: data.endpointFactory.createReadTodosEndpoint(data.user.id),
getItemCountEndpoint: data.endpointFactory.createReadTodoCountEndpoint(data.user.id),
defaultSortField: 'updated',
defaultSortOrder: 'desc',
};
</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">
import Table from '$lib/components/data-table/Table.svelte';
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[] = [
{
field: "id",
heading: "ID",
field: 'id',
heading: 'ID',
type: 'number',
},
{
field: "email",
heading: "Email",
field: 'email',
heading: 'Email',
isLink: true,
linkTarget: '/users/%id%',
},
{
field: "first_name",
heading: "First Name",
field: 'first_name',
heading: 'First Name',
},
{
field: "last_name",
heading: "Last Name",
field: 'last_name',
heading: 'Last Name',
},
{
field: "created",
heading: "Created",
field: 'created',
heading: 'Created',
type: 'date',
},
{
field: "updated",
heading: "Updated",
field: 'updated',
heading: 'Updated',
type: 'date',
},
{
field: "is_admin",
heading: "Admin",
field: 'is_admin',
heading: 'Admin',
type: 'boolean',
},
]
@ -42,14 +45,10 @@
const table = {
caption: "List of users",
columns: columns,
getItemsEndpoint: {
callable: readUsers,
args: []
},
getItemCountEndpoint: {
callable: readUserCount,
args: []
}
getItemsEndpoint: data.endpointFactory.createReadUsersEndpoint(),
getItemCountEndpoint: data.endpointFactory.createReadUserCountEndpoint(),
defaultSortField: 'id',
defaultSortOrder: 'asc',
};
</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",
current: "currentColor",
primary: {
50: "#D1DAF0",
100: "#859DD6",
200: "#5778C7",
300: "#385AA8",
400: "#29417A",
500: "#1F315C",
DEFAULT: "#1F315C",
600: "#14213D",
700: "#0F192E",
800: "#0A111F",
900: "#05080F",
50: "#D5E8EC",
100: "#9CCAD3",
200: "#4B9AAA",
300: "#326671",
400: "#1F4047",
500: "#0D1B1E",
DEFAULT: "#0D1B1E",
600: "#0C191C",
700: "#070F11",
800: "#040809",
900: "#000000",
},
secondary: {
50: "#DBF5FA",
100: "#B7EBF5",
200: "#81DCEE",
300: "#4BCDE7",
400: "#1DB8D7",
500: "#189AB4",
DEFAULT: "#189AB4",
600: "#147B90",
700: "#0F5C6C",
800: "#0A3D48",
900: "#051E24",
50: "#F3F7F5",
100: "#DBE6E2",
200: "#B6CDC5",
300: "#9EBDB2",
400: "#86AC9F",
500: "#6C9A8B",
DEFAULT: "#6C9A8B",
600: "#5B8677",
700: "#4A6D61",
800: "#3A554B",
900: "#192420",
},
accent: {
50: "#FED7DE",
100: "#FD9BAD",
200: "#FC738C",
300: "#FB4B6B",
400: "#FB234B",
500: "#DC042C",
DEFAULT: "#DC042C",
600: "#9A031E",
700: "#780218",
800: "#500110",
900: "#280008",
50: "#FFF9EB",
100: "#FFEDC2",
200: "#FFE7AD",
300: "#FFDA85",
400: "#FFCE5C",
500: "#FFC233",
DEFAULT: "#FFC233",
600: "#FFB60A",
700: "#E09D00",
800: "#A37200",
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: {
'3xl': '1920px',