diff options
Diffstat (limited to 'fe/src')
| -rw-r--r-- | fe/src/App.svelte | 16 | ||||
| -rw-r--r-- | fe/src/app.css | 120 | ||||
| -rw-r--r-- | fe/src/assets/svelte.svg | 1 | ||||
| -rw-r--r-- | fe/src/errors.ts | 5 | ||||
| -rw-r--r-- | fe/src/http.ts | 92 | ||||
| -rw-r--r-- | fe/src/lib/Card.svelte | 23 | ||||
| -rw-r--r-- | fe/src/lib/Chart.svelte | 63 | ||||
| -rw-r--r-- | fe/src/lib/Column.svelte | 13 | ||||
| -rw-r--r-- | fe/src/lib/DataView.svelte | 228 | ||||
| -rw-r--r-- | fe/src/lib/Layout.svelte | 74 | ||||
| -rw-r--r-- | fe/src/lib/LoginForm.svelte | 86 | ||||
| -rw-r--r-- | fe/src/lib/PreferencesForm.svelte | 119 | ||||
| -rw-r--r-- | fe/src/lib/Table.svelte | 114 | ||||
| -rw-r--r-- | fe/src/lib/forms/AddForm.svelte | 78 | ||||
| -rw-r--r-- | fe/src/main.ts | 8 | ||||
| -rw-r--r-- | fe/src/stores/auth.ts | 90 | ||||
| -rw-r--r-- | fe/src/stores/forms.ts | 6 | ||||
| -rw-r--r-- | fe/src/types.ts | 53 | ||||
| -rw-r--r-- | fe/src/utils.ts | 14 | ||||
| -rw-r--r-- | fe/src/vite-env.d.ts | 2 |
20 files changed, 1205 insertions, 0 deletions
diff --git a/fe/src/App.svelte b/fe/src/App.svelte new file mode 100644 index 0000000..25d53dc --- /dev/null +++ b/fe/src/App.svelte | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import Layout from "./lib/Layout.svelte"; | ||
| 3 | import LoginForm from "./lib/LoginForm.svelte"; | ||
| 4 | import DataView from "./lib/DataView.svelte"; | ||
| 5 | import { authenticated } from "./stores/auth"; | ||
| 6 | </script> | ||
| 7 | |||
| 8 | <main> | ||
| 9 | <Layout> | ||
| 10 | {#if !$authenticated} | ||
| 11 | <LoginForm /> | ||
| 12 | {:else} | ||
| 13 | <DataView /> | ||
| 14 | {/if} | ||
| 15 | </Layout> | ||
| 16 | </main> | ||
diff --git a/fe/src/app.css b/fe/src/app.css new file mode 100644 index 0000000..c24c713 --- /dev/null +++ b/fe/src/app.css | |||
| @@ -0,0 +1,120 @@ | |||
| 1 | :root { | ||
| 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; | ||
| 3 | line-height: 1.5; | ||
| 4 | font-weight: 400; | ||
| 5 | |||
| 6 | color-scheme: light dark; | ||
| 7 | color: rgba(255, 255, 255, 0.87); | ||
| 8 | background-color: #242424; | ||
| 9 | |||
| 10 | font-synthesis: none; | ||
| 11 | text-rendering: optimizeLegibility; | ||
| 12 | -webkit-font-smoothing: antialiased; | ||
| 13 | -moz-osx-font-smoothing: grayscale; | ||
| 14 | |||
| 15 | --submit: #28a745; | ||
| 16 | } | ||
| 17 | |||
| 18 | a { | ||
| 19 | font-weight: 500; | ||
| 20 | color: #646cff; | ||
| 21 | text-decoration: inherit; | ||
| 22 | } | ||
| 23 | |||
| 24 | a:hover { | ||
| 25 | color: #535bf2; | ||
| 26 | } | ||
| 27 | |||
| 28 | body { | ||
| 29 | margin: 0; | ||
| 30 | display: flex; | ||
| 31 | place-items: center; | ||
| 32 | min-width: 320px; | ||
| 33 | min-height: 100vh; | ||
| 34 | } | ||
| 35 | |||
| 36 | h1 { | ||
| 37 | font-size: 3.2em; | ||
| 38 | line-height: 1.1; | ||
| 39 | } | ||
| 40 | |||
| 41 | .card { | ||
| 42 | padding: 2em; | ||
| 43 | } | ||
| 44 | |||
| 45 | #app { | ||
| 46 | flex-grow: 2; | ||
| 47 | max-width: 1280px; | ||
| 48 | margin: 0 auto; | ||
| 49 | } | ||
| 50 | |||
| 51 | button { | ||
| 52 | border-radius: 8px; | ||
| 53 | border: 1px solid transparent; | ||
| 54 | padding: 0.6em 1.2em; | ||
| 55 | font-size: 1em; | ||
| 56 | font-weight: 500; | ||
| 57 | font-family: inherit; | ||
| 58 | background-color: #1a1a1a; | ||
| 59 | cursor: pointer; | ||
| 60 | transition: border-color 0.25s; | ||
| 61 | } | ||
| 62 | |||
| 63 | button:hover { | ||
| 64 | border-color: #646cff; | ||
| 65 | } | ||
| 66 | |||
| 67 | button:focus, | ||
| 68 | button:focus-visible { | ||
| 69 | outline: 4px auto -webkit-focus-ring-color; | ||
| 70 | } | ||
| 71 | |||
| 72 | @media (prefers-color-scheme: light) { | ||
| 73 | :root { | ||
| 74 | color: #213547; | ||
| 75 | background-color: #ffffff; | ||
| 76 | } | ||
| 77 | |||
| 78 | a:hover { | ||
| 79 | color: #747bff; | ||
| 80 | } | ||
| 81 | |||
| 82 | button { | ||
| 83 | background-color: #f9f9f9; | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | @media (prefers-color-scheme: dark) { | ||
| 88 | :root { | ||
| 89 | color: #000; | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | .form { | ||
| 94 | display: flex; | ||
| 95 | flex-direction: column; | ||
| 96 | } | ||
| 97 | |||
| 98 | .form.input.group { | ||
| 99 | display: flex; | ||
| 100 | flex-direction: column; | ||
| 101 | margin-bottom: 1em; | ||
| 102 | } | ||
| 103 | |||
| 104 | .form.input.group label { | ||
| 105 | margin-bottom: .5em; | ||
| 106 | } | ||
| 107 | |||
| 108 | .form.input.group input { | ||
| 109 | padding: 1em; | ||
| 110 | } | ||
| 111 | |||
| 112 | .form.input.group input[type=color] { | ||
| 113 | padding: 0; | ||
| 114 | } | ||
| 115 | |||
| 116 | .form button[type=submit] { | ||
| 117 | align-self: flex-end; | ||
| 118 | background: var(--submit); | ||
| 119 | color: #fff; | ||
| 120 | } | ||
diff --git a/fe/src/assets/svelte.svg b/fe/src/assets/svelte.svg new file mode 100644 index 0000000..c5e0848 --- /dev/null +++ b/fe/src/assets/svelte.svg | |||
| @@ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg> \ No newline at end of file | |||
diff --git a/fe/src/errors.ts b/fe/src/errors.ts new file mode 100644 index 0000000..81f7145 --- /dev/null +++ b/fe/src/errors.ts | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | export class UnauthorizedError extends Error { | ||
| 2 | constructor(message?: string) { | ||
| 3 | super(message); | ||
| 4 | } | ||
| 5 | } | ||
diff --git a/fe/src/http.ts b/fe/src/http.ts new file mode 100644 index 0000000..3b2a4f0 --- /dev/null +++ b/fe/src/http.ts | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | let instance; | ||
| 2 | const baseUrl = import.meta.env?.VITE_API_BASE_URL ?? "http://localhost:8080/api/v1"; | ||
| 3 | |||
| 4 | class HttpClient { | ||
| 5 | baseURL: string; | ||
| 6 | commonHeaders: Headers; | ||
| 7 | |||
| 8 | constructor(baseURL: string) { | ||
| 9 | this.baseURL = baseURL; | ||
| 10 | this.commonHeaders = new Headers({ | ||
| 11 | "Content-Type": "application/json" | ||
| 12 | }) | ||
| 13 | if (instance) { | ||
| 14 | throw new Error("New instance cannot be created!"); | ||
| 15 | } | ||
| 16 | |||
| 17 | instance = this; | ||
| 18 | } | ||
| 19 | |||
| 20 | private getURL(endpoint: string): URL { | ||
| 21 | return new URL(endpoint, this.baseURL); | ||
| 22 | } | ||
| 23 | |||
| 24 | private token(): string | null { | ||
| 25 | return localStorage.getItem('token'); | ||
| 26 | } | ||
| 27 | |||
| 28 | private async makeRequest(request: Request): Promise<Response> { | ||
| 29 | return fetch(request) | ||
| 30 | } | ||
| 31 | |||
| 32 | async get({ endpoint, headers }: IHttpParameters): Promise<Response> { | ||
| 33 | const url: URL = this.getURL(endpoint); | ||
| 34 | headers = Object.assign<Headers, Headers>(headers, this.commonHeaders); | ||
| 35 | const request: Request = new Request(url, { | ||
| 36 | method: "GET", | ||
| 37 | headers | ||
| 38 | }); | ||
| 39 | |||
| 40 | return this.makeRequest(request); | ||
| 41 | } | ||
| 42 | |||
| 43 | async post({ endpoint, authenticated, body, headers }: IHttpParameters): Promise<Response> { | ||
| 44 | const url = this.getURL(endpoint); | ||
| 45 | |||
| 46 | if (authenticated) { | ||
| 47 | const token: string | null = this.token(); | ||
| 48 | headers.append('Authorization', `Bearer ${token}`); | ||
| 49 | } | ||
| 50 | |||
| 51 | const request: Request = new Request(url, { | ||
| 52 | method: "POST", | ||
| 53 | body: JSON.stringify(body), | ||
| 54 | headers | ||
| 55 | }) | ||
| 56 | |||
| 57 | return this.makeRequest(request); | ||
| 58 | } | ||
| 59 | |||
| 60 | async patch({ endpoint, authenticated, headers }: IHttpParameters): Promise<Response> { | ||
| 61 | const url = this.getURL(endpoint); | ||
| 62 | if (authenticated) { | ||
| 63 | |||
| 64 | } | ||
| 65 | const response: Response = await fetch(url, { | ||
| 66 | method: "PATCH", | ||
| 67 | headers | ||
| 68 | }); | ||
| 69 | } | ||
| 70 | |||
| 71 | async delete({ endpoint, authenticated, headers }: IHttpParameters): Promise<Response> { | ||
| 72 | const url = this.getURL(endpoint); | ||
| 73 | if (authenticated) { | ||
| 74 | |||
| 75 | } | ||
| 76 | const response: Response = await fetch(url, { | ||
| 77 | method: "DELETE", | ||
| 78 | headers | ||
| 79 | }) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | interface IHttpParameters { | ||
| 84 | endpoint: string; | ||
| 85 | body: Record<string, any>; | ||
| 86 | authenticated: boolean; | ||
| 87 | headers: Headers; | ||
| 88 | } | ||
| 89 | |||
| 90 | let http: Readonly<HttpClient> = Object.freeze(new HttpClient(baseUrl)); | ||
| 91 | |||
| 92 | export default http; | ||
diff --git a/fe/src/lib/Card.svelte b/fe/src/lib/Card.svelte new file mode 100644 index 0000000..cd1e02c --- /dev/null +++ b/fe/src/lib/Card.svelte | |||
| @@ -0,0 +1,23 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | export let title = ""; | ||
| 3 | </script> | ||
| 4 | |||
| 5 | <div class="card"> | ||
| 6 | {#if title} | ||
| 7 | <h2>{title}</h2> | ||
| 8 | {/if} | ||
| 9 | <slot /> | ||
| 10 | </div> | ||
| 11 | |||
| 12 | <style> | ||
| 13 | .card { | ||
| 14 | background: #fff; | ||
| 15 | display: flex; | ||
| 16 | flex-direction: column; | ||
| 17 | align-items: flex-start; | ||
| 18 | border: solid 2px #00000066; | ||
| 19 | border-radius: 0.25em; | ||
| 20 | height: var(--height, fit-content); | ||
| 21 | overflow-y: var(--overflow, initial); | ||
| 22 | } | ||
| 23 | </style> | ||
diff --git a/fe/src/lib/Chart.svelte b/fe/src/lib/Chart.svelte new file mode 100644 index 0000000..b19d932 --- /dev/null +++ b/fe/src/lib/Chart.svelte | |||
| @@ -0,0 +1,63 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { onDestroy } from "svelte"; | ||
| 3 | import ChartJS from "chart.js/auto"; | ||
| 4 | |||
| 5 | export let data; | ||
| 6 | export let labels; | ||
| 7 | export let type = 'bar'; | ||
| 8 | |||
| 9 | let ref: HTMLCanvasElement; | ||
| 10 | let chart | ||
| 11 | |||
| 12 | function setupChart(result) { | ||
| 13 | [labels, data] = result; | ||
| 14 | chart = new ChartJS(ref, { | ||
| 15 | type, | ||
| 16 | data: { | ||
| 17 | labels, | ||
| 18 | datasets: [ | ||
| 19 | { | ||
| 20 | label: "Totals", | ||
| 21 | data, | ||
| 22 | backgroundColor: "rgba(255, 192, 192, 0.2)" | ||
| 23 | } | ||
| 24 | ] | ||
| 25 | }, | ||
| 26 | options: { | ||
| 27 | responsive: true, | ||
| 28 | maintainAspectRatio: false, | ||
| 29 | scales: { | ||
| 30 | y: { | ||
| 31 | suggestedMax: 30, | ||
| 32 | beginAtZero: true, | ||
| 33 | ticks: { | ||
| 34 | autoSkip: true, | ||
| 35 | stepSize: 5 | ||
| 36 | } | ||
| 37 | } | ||
| 38 | }, | ||
| 39 | plugins: { | ||
| 40 | legend: { | ||
| 41 | display: false | ||
| 42 | }, | ||
| 43 | title: { | ||
| 44 | display: true, | ||
| 45 | text: "Weekly Breakdown" | ||
| 46 | }, | ||
| 47 | subtitle: { | ||
| 48 | display: true, | ||
| 49 | text: "Water consumption over the last week", | ||
| 50 | padding: {bottom: 10} | ||
| 51 | } | ||
| 52 | } | ||
| 53 | } | ||
| 54 | }); | ||
| 55 | |||
| 56 | onDestroy(() => { | ||
| 57 | if (chart) chart.destroy(); | ||
| 58 | chart = null; | ||
| 59 | }) | ||
| 60 | } | ||
| 61 | </script> | ||
| 62 | |||
| 63 | <canvas bind:this={ref} /> \ No newline at end of file | ||
diff --git a/fe/src/lib/Column.svelte b/fe/src/lib/Column.svelte new file mode 100644 index 0000000..f036073 --- /dev/null +++ b/fe/src/lib/Column.svelte | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | <div class="column"> | ||
| 2 | <slot /> | ||
| 3 | </div> | ||
| 4 | |||
| 5 | <style> | ||
| 6 | .column { | ||
| 7 | display: flex; | ||
| 8 | flex-direction: column; | ||
| 9 | height: 100%; | ||
| 10 | gap: var(--gap, 32px); | ||
| 11 | width: var(--width, initial); | ||
| 12 | } | ||
| 13 | </style> \ No newline at end of file | ||
diff --git a/fe/src/lib/DataView.svelte b/fe/src/lib/DataView.svelte new file mode 100644 index 0000000..ffc2fe8 --- /dev/null +++ b/fe/src/lib/DataView.svelte | |||
| @@ -0,0 +1,228 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { onDestroy, onMount } from "svelte"; | ||
| 3 | import http from "../http"; | ||
| 4 | import { token } from "../stores/auth"; | ||
| 5 | import { addFormOpen } from "../stores/forms"; | ||
| 6 | import Table from "./Table.svelte"; | ||
| 7 | import ChartJS from "chart.js/auto"; | ||
| 8 | import Chart from './Chart.svelte' | ||
| 9 | import Card from "./Card.svelte"; | ||
| 10 | import Column from "./Column.svelte"; | ||
| 11 | import AddForm from "./forms/AddForm.svelte"; | ||
| 12 | import { apiURL } from "../utils"; | ||
| 13 | |||
| 14 | let json: Promise<any>; | ||
| 15 | |||
| 16 | let barCanvasRef: HTMLCanvasElement; | ||
| 17 | let lineCanvasRef: HTMLCanvasElement; | ||
| 18 | let barChart: any; | ||
| 19 | let lineChart: any; | ||
| 20 | |||
| 21 | let lastSevenDays: string[]; | ||
| 22 | let lastSevenDaysData: number[]; | ||
| 23 | |||
| 24 | let userTotalsLabels: string[]; | ||
| 25 | let userTotalsData: number[]; | ||
| 26 | |||
| 27 | async function fetchData() { | ||
| 28 | const res = await fetch(apiURL("stats"), { | ||
| 29 | method: "GET", | ||
| 30 | headers: { | ||
| 31 | Authorization: `Bearer ${$token}` | ||
| 32 | } | ||
| 33 | }); | ||
| 34 | if (res.ok) { | ||
| 35 | json = res.json(); | ||
| 36 | } else { | ||
| 37 | throw new Error("There was a problem with your request"); | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | async function fetchDailyUserStatistics() { | ||
| 42 | const res = await fetch(apiURL("stats/daily"), { | ||
| 43 | method: "GET", | ||
| 44 | headers: { | ||
| 45 | Authorization: `Bearer ${$token}` | ||
| 46 | } | ||
| 47 | }); | ||
| 48 | |||
| 49 | if (res.ok) { | ||
| 50 | const json = await res.json(); | ||
| 51 | let labels = json.map((d: any) => d.name); | ||
| 52 | let data = json.map((d: any) => d.total); | ||
| 53 | return [labels, data]; | ||
| 54 | } else { | ||
| 55 | throw new Error("There was a problem with your request"); | ||
| 56 | } | ||
| 57 | |||
| 58 | } | ||
| 59 | |||
| 60 | async function fetchWeeklyTotals() { | ||
| 61 | const res = await fetch(apiURL("stats/weekly"), { | ||
| 62 | method: "GET", | ||
| 63 | headers: { | ||
| 64 | Authorization: `Bearer ${$token}` | ||
| 65 | } | ||
| 66 | }); | ||
| 67 | |||
| 68 | if (res.ok) { | ||
| 69 | const json = await res.json(); | ||
| 70 | let labels = json.map((d: any) => d.date); | ||
| 71 | let data = json.map((d: any) => d.total); | ||
| 72 | return [labels, data]; | ||
| 73 | } else { | ||
| 74 | throw new Error("There was a problem with your request"); | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | function closeDialog() { | ||
| 79 | addFormOpen.set(false); | ||
| 80 | } | ||
| 81 | |||
| 82 | function onStatisticAdd() { | ||
| 83 | closeDialog(); | ||
| 84 | fetchData(); | ||
| 85 | fetchWeeklyTotals().then(updateWeeklyTotalsChart).catch(err => console.error(err)); | ||
| 86 | fetchDailyUserStatistics().then(updateDailyUserTotalsChart).catch(err => console.error(err)); | ||
| 87 | } | ||
| 88 | |||
| 89 | function setupWeeklyTotalsChart(result: any) { | ||
| 90 | [lastSevenDays, lastSevenDaysData] = result; | ||
| 91 | lineChart = new ChartJS(lineCanvasRef, { | ||
| 92 | type: "line", | ||
| 93 | data: { | ||
| 94 | labels: lastSevenDays, | ||
| 95 | datasets: [ | ||
| 96 | { | ||
| 97 | label: "Totals", | ||
| 98 | data: lastSevenDaysData, | ||
| 99 | backgroundColor: "rgba(255, 192, 192, 0.2)" | ||
| 100 | } | ||
| 101 | ] | ||
| 102 | }, | ||
| 103 | options: { | ||
| 104 | responsive: true, | ||
| 105 | maintainAspectRatio: false, | ||
| 106 | scales: { | ||
| 107 | y: { | ||
| 108 | suggestedMax: 30, | ||
| 109 | beginAtZero: true, | ||
| 110 | ticks: { | ||
| 111 | autoSkip: true, | ||
| 112 | stepSize: 5 | ||
| 113 | } | ||
| 114 | } | ||
| 115 | }, | ||
| 116 | plugins: { | ||
| 117 | legend: { | ||
| 118 | display: false | ||
| 119 | }, | ||
| 120 | title: { | ||
| 121 | display: true, | ||
| 122 | text: "Weekly Breakdown" | ||
| 123 | }, | ||
| 124 | subtitle: { | ||
| 125 | display: true, | ||
| 126 | text: "Water consumption over the last week", | ||
| 127 | padding: {bottom: 10} | ||
| 128 | } | ||
| 129 | } | ||
| 130 | } | ||
| 131 | }); | ||
| 132 | } | ||
| 133 | |||
| 134 | function setupDailyUserTotalsChart(result: any) { | ||
| 135 | [userTotalsLabels, userTotalsData] = result; | ||
| 136 | |||
| 137 | barChart = new ChartJS(barCanvasRef, { | ||
| 138 | type: "bar", | ||
| 139 | data: { | ||
| 140 | labels: userTotalsLabels, | ||
| 141 | datasets: [ | ||
| 142 | { | ||
| 143 | data: userTotalsData, | ||
| 144 | backgroundColor: [ | ||
| 145 | "#330000", | ||
| 146 | "rgba(100, 200, 192, 0.2)" | ||
| 147 | ] | ||
| 148 | } | ||
| 149 | ] | ||
| 150 | }, | ||
| 151 | options: { | ||
| 152 | responsive: true, | ||
| 153 | maintainAspectRatio: false, | ||
| 154 | scales: { | ||
| 155 | y: { | ||
| 156 | beginAtZero: true, | ||
| 157 | suggestedMax: 10, | ||
| 158 | ticks: { | ||
| 159 | autoSkip: true, | ||
| 160 | stepSize: 1 | ||
| 161 | } | ||
| 162 | } | ||
| 163 | }, | ||
| 164 | plugins: { | ||
| 165 | legend: { | ||
| 166 | display: false | ||
| 167 | }, | ||
| 168 | title: { | ||
| 169 | display: true, | ||
| 170 | text: "Daily Total" | ||
| 171 | }, | ||
| 172 | subtitle: { | ||
| 173 | display: true, | ||
| 174 | text: "Water Drank Today", | ||
| 175 | padding: {bottom: 10} | ||
| 176 | } | ||
| 177 | } | ||
| 178 | } | ||
| 179 | }); | ||
| 180 | } | ||
| 181 | |||
| 182 | function updateWeeklyTotalsChart(result: any) { | ||
| 183 | [, lastSevenDaysData] = result; | ||
| 184 | lineChart.data.datasets[0].data = lastSevenDaysData; | ||
| 185 | lineChart.update(); | ||
| 186 | } | ||
| 187 | |||
| 188 | function updateDailyUserTotalsChart(result: any) { | ||
| 189 | [, userTotalsData] = result; | ||
| 190 | barChart.data.datasets[0].data = userTotalsData; | ||
| 191 | barChart.update(); | ||
| 192 | } | ||
| 193 | |||
| 194 | onMount(() => { | ||
| 195 | fetchData(); | ||
| 196 | fetchWeeklyTotals().then(setupWeeklyTotalsChart); | ||
| 197 | fetchDailyUserStatistics().then(setupDailyUserTotalsChart); | ||
| 198 | }); | ||
| 199 | |||
| 200 | onDestroy(() => { | ||
| 201 | if (barChart) barChart.destroy(); | ||
| 202 | if (lineChart) lineChart.destroy(); | ||
| 203 | barChart = null; | ||
| 204 | lineChart = null; | ||
| 205 | }); | ||
| 206 | </script> | ||
| 207 | |||
| 208 | <Column --width="500px"> | ||
| 209 | <Card --height="300px"> | ||
| 210 | <!--<Chart />--> | ||
| 211 | <canvas bind:this={barCanvasRef} /> | ||
| 212 | </Card> | ||
| 213 | <Card --height="300px"> | ||
| 214 | <canvas bind:this={lineCanvasRef} /> | ||
| 215 | </Card> | ||
| 216 | </Column> | ||
| 217 | |||
| 218 | <AddForm open={$addFormOpen} on:submit={onStatisticAdd} on:close={closeDialog} /> | ||
| 219 | <Column> | ||
| 220 | <Card> | ||
| 221 | {#await json then data} | ||
| 222 | <Table {data} nofooter /> | ||
| 223 | {:catch error} | ||
| 224 | <p>{error}</p> | ||
| 225 | {/await} | ||
| 226 | </Card> | ||
| 227 | </Column> | ||
| 228 | <!-- <Chart /> --> | ||
diff --git a/fe/src/lib/Layout.svelte b/fe/src/lib/Layout.svelte new file mode 100644 index 0000000..2728dd3 --- /dev/null +++ b/fe/src/lib/Layout.svelte | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | <script> | ||
| 2 | import { authenticated, token, user, preferences } from "../stores/auth"; | ||
| 3 | import PreferencesForm from "./PreferencesForm.svelte"; | ||
| 4 | import { addFormOpen } from "../stores/forms"; | ||
| 5 | |||
| 6 | const logout = () => { | ||
| 7 | preferences.reset(); | ||
| 8 | user.reset(); | ||
| 9 | token.unauthenticate(); | ||
| 10 | } | ||
| 11 | let preferenceFormOpen = false; | ||
| 12 | |||
| 13 | function showPreferencesDialog() { | ||
| 14 | preferenceFormOpen = true; | ||
| 15 | } | ||
| 16 | |||
| 17 | function closePreferenceDialog() { | ||
| 18 | preferenceFormOpen = false; | ||
| 19 | } | ||
| 20 | |||
| 21 | function showAddDialog() { | ||
| 22 | addFormOpen.set(true); | ||
| 23 | } | ||
| 24 | </script> | ||
| 25 | |||
| 26 | <div class="layout"> | ||
| 27 | {#if $authenticated} | ||
| 28 | <nav> | ||
| 29 | <div> | ||
| 30 | <h1>Water</h1> | ||
| 31 | </div> | ||
| 32 | <div> | ||
| 33 | <button on:click={showAddDialog}>Log Water</button> | ||
| 34 | <button on:click={showPreferencesDialog}>Preference</button> | ||
| 35 | <button on:click={logout}>Logout</button> | ||
| 36 | </div> | ||
| 37 | </nav> | ||
| 38 | <PreferencesForm open={preferenceFormOpen} on:close={closePreferenceDialog} /> | ||
| 39 | {/if} | ||
| 40 | <div id="content"> | ||
| 41 | <slot /> | ||
| 42 | </div> | ||
| 43 | </div> | ||
| 44 | |||
| 45 | <style> | ||
| 46 | .layout { | ||
| 47 | height: 100vh; | ||
| 48 | } | ||
| 49 | |||
| 50 | nav { | ||
| 51 | display: flex; | ||
| 52 | flex-direction: row; | ||
| 53 | align-items: center; | ||
| 54 | justify-content: space-between; | ||
| 55 | height: 64px; | ||
| 56 | padding: 0 2em; | ||
| 57 | } | ||
| 58 | |||
| 59 | nav div { | ||
| 60 | width: fit-content; | ||
| 61 | } | ||
| 62 | |||
| 63 | nav div h1 { | ||
| 64 | font-size: 1.75em; | ||
| 65 | } | ||
| 66 | |||
| 67 | #content { | ||
| 68 | display: flex; | ||
| 69 | flex-direction: row; | ||
| 70 | justify-content: center; | ||
| 71 | gap: 2em; | ||
| 72 | margin-top: 4em; | ||
| 73 | } | ||
| 74 | </style> | ||
diff --git a/fe/src/lib/LoginForm.svelte b/fe/src/lib/LoginForm.svelte new file mode 100644 index 0000000..cf5febf --- /dev/null +++ b/fe/src/lib/LoginForm.svelte | |||
| @@ -0,0 +1,86 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { token, user, preferences } from "../stores/auth"; | ||
| 3 | import Card from "./Card.svelte"; | ||
| 4 | import { apiURL } from "../utils"; | ||
| 5 | |||
| 6 | let credentials: CredentialObject = { | ||
| 7 | username: "", | ||
| 8 | password: "", | ||
| 9 | }; | ||
| 10 | |||
| 11 | let error: string | null = null; | ||
| 12 | |||
| 13 | interface CredentialObject { | ||
| 14 | username: string; | ||
| 15 | password: string; | ||
| 16 | } | ||
| 17 | |||
| 18 | function prepareCredentials({ | ||
| 19 | username, | ||
| 20 | password, | ||
| 21 | }: CredentialObject): string { | ||
| 22 | return btoa(`${username}:${password}`); | ||
| 23 | } | ||
| 24 | |||
| 25 | async function onSubmit(e: Event) { | ||
| 26 | if (!credentials.username || !credentials.password) { | ||
| 27 | error = "please enter your username and password"; | ||
| 28 | return; | ||
| 29 | } | ||
| 30 | const auth = prepareCredentials(credentials); | ||
| 31 | |||
| 32 | const response = await fetch(apiURL("auth"), { | ||
| 33 | method: "POST", | ||
| 34 | headers: { | ||
| 35 | Authorization: `Basic ${auth}`, | ||
| 36 | }, | ||
| 37 | }); | ||
| 38 | |||
| 39 | if (response.status === 401) { | ||
| 40 | error = "Your username or password is wrong"; | ||
| 41 | return; | ||
| 42 | } | ||
| 43 | |||
| 44 | if (response.ok) { | ||
| 45 | const { | ||
| 46 | token: apiToken, | ||
| 47 | user: userData, | ||
| 48 | preferences: userPreferences, | ||
| 49 | } = await response.json(); | ||
| 50 | user.setUser(userData); | ||
| 51 | preferences.setPreference(userPreferences); | ||
| 52 | token.authenticate(apiToken); | ||
| 53 | } | ||
| 54 | |||
| 55 | error = null; | ||
| 56 | } | ||
| 57 | </script> | ||
| 58 | |||
| 59 | <Card> | ||
| 60 | <form class="form" on:submit|preventDefault={onSubmit}> | ||
| 61 | <div class="form input group"> | ||
| 62 | <label for="username">Username</label> | ||
| 63 | <input | ||
| 64 | bind:value={credentials.username} | ||
| 65 | id="username" | ||
| 66 | name="username" | ||
| 67 | type="text" | ||
| 68 | autocomplete="username" | ||
| 69 | /> | ||
| 70 | </div> | ||
| 71 | <div class="form input group"> | ||
| 72 | <label for="password">Password</label> | ||
| 73 | <input | ||
| 74 | bind:value={credentials.password} | ||
| 75 | id="password" | ||
| 76 | name="password" | ||
| 77 | type="password" | ||
| 78 | autocomplete="current-password" | ||
| 79 | /> | ||
| 80 | </div> | ||
| 81 | {#if error} | ||
| 82 | <p class="error">{error}</p> | ||
| 83 | {/if} | ||
| 84 | <button type="submit">Log in</button> | ||
| 85 | </form> | ||
| 86 | </Card> | ||
diff --git a/fe/src/lib/PreferencesForm.svelte b/fe/src/lib/PreferencesForm.svelte new file mode 100644 index 0000000..74b8a63 --- /dev/null +++ b/fe/src/lib/PreferencesForm.svelte | |||
| @@ -0,0 +1,119 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { user, preferences, token } from "../stores/auth"; | ||
| 3 | import { createEventDispatcher, onDestroy, onMount } from "svelte"; | ||
| 4 | import type { User } from "../types"; | ||
| 5 | import { apiURL } from "../utils"; | ||
| 6 | |||
| 7 | export let open: boolean; | ||
| 8 | |||
| 9 | let sizes: Array<any>; | ||
| 10 | let selectedSize: number = 1; | ||
| 11 | let color: string = "#000000"; | ||
| 12 | |||
| 13 | const dispatch = createEventDispatcher(); | ||
| 14 | |||
| 15 | const unsubscribe = preferences.subscribe( | ||
| 16 | (value: any) => { | ||
| 17 | if (value) { | ||
| 18 | color = value.color; | ||
| 19 | selectedSize = value.size_id; | ||
| 20 | } | ||
| 21 | }, | ||
| 22 | ); | ||
| 23 | |||
| 24 | function closeDialog() { | ||
| 25 | dispatch("close"); | ||
| 26 | } | ||
| 27 | |||
| 28 | async function updateUserPreferences() { | ||
| 29 | const res = await fetch(apiURL("user/preferences"), { | ||
| 30 | method: "PATCH", | ||
| 31 | headers: { | ||
| 32 | Authorization: `Bearer ${$token}`, | ||
| 33 | }, | ||
| 34 | body: JSON.stringify($preferences), | ||
| 35 | }); | ||
| 36 | } | ||
| 37 | |||
| 38 | async function getUserPreferences() { | ||
| 39 | const res = await fetch(apiURL(`user/${($user as User)!.id}/preferences`), | ||
| 40 | { | ||
| 41 | method: "GET", | ||
| 42 | headers: { | ||
| 43 | Authorization: `Bearer ${$token}`, | ||
| 44 | }, | ||
| 45 | }, | ||
| 46 | ); | ||
| 47 | const updatePreferences = await res.json(); | ||
| 48 | preferences.set(updatePreferences); | ||
| 49 | } | ||
| 50 | |||
| 51 | async function onPreferencesSave(): Promise<void> { | ||
| 52 | preferences.update((value) => ({ | ||
| 53 | ...value!, | ||
| 54 | size_id: selectedSize, | ||
| 55 | color: color, | ||
| 56 | })); | ||
| 57 | |||
| 58 | await updateUserPreferences(); | ||
| 59 | await getUserPreferences(); | ||
| 60 | |||
| 61 | dispatch("close"); | ||
| 62 | } | ||
| 63 | |||
| 64 | onMount(() => { | ||
| 65 | fetch(apiURL("sizes"), { | ||
| 66 | method: "GET", | ||
| 67 | headers: { | ||
| 68 | Authorization: `Bearer ${$token}`, | ||
| 69 | }, | ||
| 70 | }) | ||
| 71 | .then((res) => res.json()) | ||
| 72 | .then((val) => (sizes = val)); | ||
| 73 | }); | ||
| 74 | |||
| 75 | onDestroy(() => { | ||
| 76 | unsubscribe(); | ||
| 77 | }); | ||
| 78 | </script> | ||
| 79 | |||
| 80 | <dialog {open} on:submit|preventDefault={onPreferencesSave}> | ||
| 81 | <h2>User Preferences</h2> | ||
| 82 | <form method="dialog"> | ||
| 83 | <div class="form input group"> | ||
| 84 | <label for="color">Color</label> | ||
| 85 | <input | ||
| 86 | id="color" | ||
| 87 | name="color" | ||
| 88 | type="color" | ||
| 89 | bind:value={color} | ||
| 90 | /> | ||
| 91 | </div> | ||
| 92 | <div class="form input group"> | ||
| 93 | <label for="size">Bottle Size</label> | ||
| 94 | <select | ||
| 95 | bind:value={selectedSize} | ||
| 96 | > | ||
| 97 | {#if sizes} | ||
| 98 | {#each sizes as size} | ||
| 99 | <option value={size.id}>{size.size} {size.unit}</option> | ||
| 100 | {/each} | ||
| 101 | {/if} | ||
| 102 | </select> | ||
| 103 | </div> | ||
| 104 | <button on:click={closeDialog}>Cancel</button> | ||
| 105 | <button type="submit">Save</button> | ||
| 106 | </form> | ||
| 107 | </dialog> | ||
| 108 | |||
| 109 | <style> | ||
| 110 | dialog { | ||
| 111 | background: white; | ||
| 112 | color: black; | ||
| 113 | } | ||
| 114 | |||
| 115 | input[type="color"] { | ||
| 116 | width: 4em; | ||
| 117 | height: 4em; | ||
| 118 | } | ||
| 119 | </style> | ||
diff --git a/fe/src/lib/Table.svelte b/fe/src/lib/Table.svelte new file mode 100644 index 0000000..621157e --- /dev/null +++ b/fe/src/lib/Table.svelte | |||
| @@ -0,0 +1,114 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | export let data: Array<any> | undefined = undefined; | ||
| 3 | export let nofooter: boolean = false; | ||
| 4 | export let noheader: boolean = false; | ||
| 5 | export let omit: string[] = ["id"]; | ||
| 6 | export let title: string | undefined = undefined; | ||
| 7 | |||
| 8 | export let sortBy: string = 'date'; | ||
| 9 | |||
| 10 | type SortComparator = (a, b) => number | ||
| 11 | |||
| 12 | function getDataKeys(data: any[]): string[] { | ||
| 13 | if (!data || data.length === 0) return []; | ||
| 14 | return Object.keys(data[0]) | ||
| 15 | .map((k) => k.split("_").join(" ")) | ||
| 16 | .filter((k) => !omit.includes(k)); | ||
| 17 | } | ||
| 18 | |||
| 19 | function getRow(row: Record<string, any>): Array<any> { | ||
| 20 | return Object.entries(row).filter((r) => !omit.includes(r[0])); | ||
| 21 | } | ||
| 22 | |||
| 23 | function sort(arr: Array<Record<string, any>>, fn: SortComparator = (a , b) => new Date(b[sortBy]) - new Date(a[sortBy])) { | ||
| 24 | return arr.sort(fn) | ||
| 25 | } | ||
| 26 | |||
| 27 | const formatter = new Intl.DateTimeFormat("en", { | ||
| 28 | year: "numeric", | ||
| 29 | month: "numeric", | ||
| 30 | day: "numeric", | ||
| 31 | hour: "numeric", | ||
| 32 | minute: "2-digit", | ||
| 33 | second: "2-digit", | ||
| 34 | timeZone: "America/New_York" | ||
| 35 | }); | ||
| 36 | |||
| 37 | function formatDatum([key, value]: any[]) { | ||
| 38 | if (key === "date") { | ||
| 39 | const parsedDate = new Date(value); | ||
| 40 | return formatter.format(parsedDate); | ||
| 41 | } | ||
| 42 | |||
| 43 | if (key === "user") { | ||
| 44 | return value["name"]; | ||
| 45 | } | ||
| 46 | |||
| 47 | return value; | ||
| 48 | } | ||
| 49 | </script> | ||
| 50 | |||
| 51 | <table> | ||
| 52 | {#if title} | ||
| 53 | <h2>{title}</h2> | ||
| 54 | {/if} | ||
| 55 | {#if !noheader && data} | ||
| 56 | <thead> | ||
| 57 | <tr> | ||
| 58 | {#each getDataKeys(data) as header} | ||
| 59 | <th>{header}</th> | ||
| 60 | {/each} | ||
| 61 | </tr> | ||
| 62 | </thead> | ||
| 63 | {/if} | ||
| 64 | <tbody> | ||
| 65 | {#if data} | ||
| 66 | {#each sort(data) as row} | ||
| 67 | <tr> | ||
| 68 | {#each getRow(row) as datum} | ||
| 69 | <td>{formatDatum(datum)}</td> | ||
| 70 | {/each} | ||
| 71 | </tr> | ||
| 72 | {/each} | ||
| 73 | {:else} | ||
| 74 | <tr> There is not data.</tr> | ||
| 75 | {/if} | ||
| 76 | </tbody> | ||
| 77 | {#if !nofooter} | ||
| 78 | <slot name="footer"> | ||
| 79 | <tfoot> | ||
| 80 | <tr> | ||
| 81 | <td>Table Footer</td> | ||
| 82 | </tr> | ||
| 83 | </tfoot> | ||
| 84 | </slot> | ||
| 85 | {/if} | ||
| 86 | </table> | ||
| 87 | |||
| 88 | <style> | ||
| 89 | table { | ||
| 90 | padding: 16px; | ||
| 91 | margin: 8px; | ||
| 92 | border: solid 1px black; | ||
| 93 | border-collapse: collapse; | ||
| 94 | overflow-y: hidden; | ||
| 95 | } | ||
| 96 | |||
| 97 | th { | ||
| 98 | text-transform: capitalize; | ||
| 99 | } | ||
| 100 | |||
| 101 | thead tr { | ||
| 102 | background: rgba(0, 0, 23, 0.34); | ||
| 103 | } | ||
| 104 | |||
| 105 | tbody tr:nth-child(odd) { | ||
| 106 | background: rgba(0, 0, 23, 0.14); | ||
| 107 | } | ||
| 108 | |||
| 109 | th, | ||
| 110 | td { | ||
| 111 | padding: 1em; | ||
| 112 | border: 1px solid rgba(0, 0, 0, 1); | ||
| 113 | } | ||
| 114 | </style> | ||
diff --git a/fe/src/lib/forms/AddForm.svelte b/fe/src/lib/forms/AddForm.svelte new file mode 100644 index 0000000..f85cce6 --- /dev/null +++ b/fe/src/lib/forms/AddForm.svelte | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | <script lang='ts'> | ||
| 2 | import { createEventDispatcher } from "svelte"; | ||
| 3 | import { token, user } from "../../stores/auth"; | ||
| 4 | import type { Statistic } from "../../types"; | ||
| 5 | import { apiURL } from "../../utils"; | ||
| 6 | |||
| 7 | export let open: boolean; | ||
| 8 | |||
| 9 | const dispatch = createEventDispatcher(); | ||
| 10 | |||
| 11 | const statistic: Statistic = newStatistic(); | ||
| 12 | |||
| 13 | function newStatistic(): Statistic { | ||
| 14 | let now = new Date(), | ||
| 15 | month, | ||
| 16 | day, | ||
| 17 | year; | ||
| 18 | |||
| 19 | month = `${now.getMonth() + 1}`; | ||
| 20 | day = `${now.getDate()}`; | ||
| 21 | year = now.getFullYear(); | ||
| 22 | if (month.length < 2) month = "0" + month; | ||
| 23 | if (day.length < 2) day = "0" + day; | ||
| 24 | |||
| 25 | const date = [year, month, day].join("-"); | ||
| 26 | |||
| 27 | return { | ||
| 28 | user_id: $user!.uuid, | ||
| 29 | date, | ||
| 30 | quantity: 1 | ||
| 31 | }; | ||
| 32 | } | ||
| 33 | |||
| 34 | function closeDialog() { | ||
| 35 | dispatch("close"); | ||
| 36 | } | ||
| 37 | |||
| 38 | async function handleSubmitStat() | ||
| 39 | { | ||
| 40 | const { date, quantity } = statistic; | ||
| 41 | await fetch(apiURL("stats"), { | ||
| 42 | method: "POST", | ||
| 43 | headers: { | ||
| 44 | Authorization: `Bearer ${$token}` | ||
| 45 | }, | ||
| 46 | body: JSON.stringify({ | ||
| 47 | date: new Date(date), | ||
| 48 | user_id: 2, | ||
| 49 | quantity | ||
| 50 | }) | ||
| 51 | }); | ||
| 52 | dispatch("submit"); | ||
| 53 | } | ||
| 54 | |||
| 55 | </script> | ||
| 56 | |||
| 57 | <dialog {open} on:submit={handleSubmitStat}> | ||
| 58 | <h2>Add Water</h2> | ||
| 59 | <form method="dialog"> | ||
| 60 | <div class="form input group"> | ||
| 61 | <label for="date">Date:</label> | ||
| 62 | <input bind:value={statistic.date} id="date" name="date" type="date" /> | ||
| 63 | </div> | ||
| 64 | <div class="form input group"> | ||
| 65 | <label for="quantity">Quantity:</label> | ||
| 66 | <input | ||
| 67 | bind:value={statistic.quantity} | ||
| 68 | id="quantity" | ||
| 69 | name="quantity" | ||
| 70 | type="number" | ||
| 71 | min="0" | ||
| 72 | autocomplete="off" | ||
| 73 | /> | ||
| 74 | </div> | ||
| 75 | <button on:click={closeDialog}>Cancel</button> | ||
| 76 | <button type="submit">Submit</button> | ||
| 77 | </form> | ||
| 78 | </dialog> \ No newline at end of file | ||
diff --git a/fe/src/main.ts b/fe/src/main.ts new file mode 100644 index 0000000..ff866d0 --- /dev/null +++ b/fe/src/main.ts | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | import './app.css' | ||
| 2 | import App from './App.svelte' | ||
| 3 | |||
| 4 | const app = new App({ | ||
| 5 | target: document.getElementById('app') as HTMLElement, | ||
| 6 | }) | ||
| 7 | |||
| 8 | export default app | ||
diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts new file mode 100644 index 0000000..63f027e --- /dev/null +++ b/fe/src/stores/auth.ts | |||
| @@ -0,0 +1,90 @@ | |||
| 1 | import type { Preference, TokenStore, Nullable, UserStore, User, PreferenceStore } from "../types"; | ||
| 2 | import { writable, derived } from "svelte/store"; | ||
| 3 | |||
| 4 | |||
| 5 | function createTokenStore(): TokenStore { | ||
| 6 | const storedToken = localStorage.getItem("token"); | ||
| 7 | const { subscribe, set } = writable<string | null>(storedToken); | ||
| 8 | |||
| 9 | function authenticate(newToken: string): void { | ||
| 10 | try { | ||
| 11 | localStorage.setItem("token", newToken); | ||
| 12 | set(newToken); | ||
| 13 | } catch (e) { | ||
| 14 | console.error("error", e); | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | function unauthenticate(): void { | ||
| 19 | localStorage.removeItem("token"); | ||
| 20 | set(null); | ||
| 21 | } | ||
| 22 | |||
| 23 | return { | ||
| 24 | subscribe, | ||
| 25 | authenticate, | ||
| 26 | unauthenticate | ||
| 27 | }; | ||
| 28 | } | ||
| 29 | |||
| 30 | function onTokenChange($token: Nullable<string>): boolean { | ||
| 31 | return $token ? true : false; | ||
| 32 | } | ||
| 33 | |||
| 34 | function createUserStore(): UserStore { | ||
| 35 | const user = localStorage.getItem("user"); | ||
| 36 | const userObj: Nullable<User> = user ? JSON.parse(user) : null; | ||
| 37 | const { subscribe, set } = writable<User | null>(userObj); | ||
| 38 | |||
| 39 | const setUser = (user: User) => { | ||
| 40 | localStorage.setItem("user", JSON.stringify(user)); | ||
| 41 | set(user); | ||
| 42 | }; | ||
| 43 | |||
| 44 | const reset = () => { | ||
| 45 | localStorage.removeItem("user"); | ||
| 46 | set(null); | ||
| 47 | }; | ||
| 48 | |||
| 49 | return { | ||
| 50 | subscribe, | ||
| 51 | setUser, | ||
| 52 | reset | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 56 | |||
| 57 | function createPreferenceStore(): PreferenceStore { | ||
| 58 | const preferences = localStorage.getItem("preferences"); | ||
| 59 | const preferenceObj: Preference = preferences ? JSON.parse(preferences) : { | ||
| 60 | id: 0, | ||
| 61 | color: "#FF0000", | ||
| 62 | size_id: 0, | ||
| 63 | user_id: 0 | ||
| 64 | }; | ||
| 65 | |||
| 66 | const { subscribe, set, update } = writable<Nullable<Preference>>(preferenceObj); | ||
| 67 | |||
| 68 | const setPreference = (preference: Preference) => { | ||
| 69 | localStorage.setItem("preference", JSON.stringify(preference)); | ||
| 70 | set(preference); | ||
| 71 | }; | ||
| 72 | |||
| 73 | const reset = () => { | ||
| 74 | localStorage.removeItem("preference"); | ||
| 75 | set(null); | ||
| 76 | }; | ||
| 77 | |||
| 78 | return { | ||
| 79 | set, | ||
| 80 | subscribe, | ||
| 81 | reset, | ||
| 82 | update, | ||
| 83 | setPreference, | ||
| 84 | }; | ||
| 85 | } | ||
| 86 | |||
| 87 | export const token = createTokenStore(); | ||
| 88 | export const authenticated = derived(token, onTokenChange); | ||
| 89 | export const user = createUserStore(); | ||
| 90 | export const preferences = createPreferenceStore(); | ||
diff --git a/fe/src/stores/forms.ts b/fe/src/stores/forms.ts new file mode 100644 index 0000000..daf9181 --- /dev/null +++ b/fe/src/stores/forms.ts | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | import type { Writable } from "svelte/store"; | ||
| 2 | import { writable } from "svelte/store"; | ||
| 3 | |||
| 4 | |||
| 5 | export const preferencesFormOpen: Writable<boolean> = writable<boolean>(false); | ||
| 6 | export const addFormOpen: Writable<boolean> = writable<boolean>(false); \ No newline at end of file | ||
diff --git a/fe/src/types.ts b/fe/src/types.ts new file mode 100644 index 0000000..c8f2f00 --- /dev/null +++ b/fe/src/types.ts | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | import type { Invalidator, Subscriber, Unsubscriber, Updater } from "svelte/store"; | ||
| 2 | |||
| 3 | export interface Preference { | ||
| 4 | id: number; | ||
| 5 | color: string; | ||
| 6 | size_id: number; | ||
| 7 | user_id: number; | ||
| 8 | } | ||
| 9 | |||
| 10 | export interface Size { | ||
| 11 | size: number; | ||
| 12 | unit: string; | ||
| 13 | } | ||
| 14 | |||
| 15 | export interface User { | ||
| 16 | id: number; | ||
| 17 | name: string; | ||
| 18 | uuid: string; | ||
| 19 | } | ||
| 20 | |||
| 21 | export interface Statistic { | ||
| 22 | user_id: string; | ||
| 23 | date: string; | ||
| 24 | quantity: number; | ||
| 25 | } | ||
| 26 | |||
| 27 | export type Nullable<T> = T | null; | ||
| 28 | |||
| 29 | export interface User { | ||
| 30 | uuid: string; | ||
| 31 | username: string; | ||
| 32 | } | ||
| 33 | |||
| 34 | export interface TokenStore { | ||
| 35 | subscribe: (run: Subscriber<Nullable<string>>, invalidate?: Invalidator<Nullable<string>>) => Unsubscriber, | ||
| 36 | authenticate: (newToken: string) => void, | ||
| 37 | unauthenticate: () => void | ||
| 38 | } | ||
| 39 | |||
| 40 | |||
| 41 | export interface UserStore { | ||
| 42 | subscribe: (run: Subscriber<Nullable<User>>, invalidate?: Invalidator<Nullable<User>>) => Unsubscriber, | ||
| 43 | setUser: (user: User) => void, | ||
| 44 | reset: () => void | ||
| 45 | } | ||
| 46 | |||
| 47 | export interface PreferenceStore { | ||
| 48 | set: (this: void, value: Preference) => void; | ||
| 49 | subscribe: (this: void, run: Subscriber<Nullable<Preference>>, invalidate?: Invalidator<Nullable<Preference>>) => Unsubscriber; | ||
| 50 | reset: () => void; | ||
| 51 | update: (this: void, updater: Updater<Nullable<Preference>>) => void; | ||
| 52 | setPreference: (user: Preference) => void; | ||
| 53 | } | ||
diff --git a/fe/src/utils.ts b/fe/src/utils.ts new file mode 100644 index 0000000..9fddf41 --- /dev/null +++ b/fe/src/utils.ts | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | export function processFormInput(form: HTMLFormElement) { | ||
| 2 | const formData: FormData = new FormData(form); | ||
| 3 | const data: Record<string, any> = {}; | ||
| 4 | for (let field of formData) { | ||
| 5 | const [key, value] = field; | ||
| 6 | data[key] = value; | ||
| 7 | } | ||
| 8 | return data; | ||
| 9 | } | ||
| 10 | |||
| 11 | export function apiURL (path: string): string { | ||
| 12 | const baseUrl = import.meta.env?.VITE_API_BASE_URL ?? "http://localhost:8080/api/v1"; | ||
| 13 | return `${baseUrl}${path}` | ||
| 14 | } | ||
diff --git a/fe/src/vite-env.d.ts b/fe/src/vite-env.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/fe/src/vite-env.d.ts | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | /// <reference types="svelte" /> | ||
| 2 | /// <reference types="vite/client" /> | ||
