aboutsummaryrefslogtreecommitdiff
path: root/fe/src
diff options
context:
space:
mode:
Diffstat (limited to 'fe/src')
-rw-r--r--fe/src/App.svelte16
-rw-r--r--fe/src/app.css120
-rw-r--r--fe/src/assets/svelte.svg1
-rw-r--r--fe/src/errors.ts5
-rw-r--r--fe/src/http.ts92
-rw-r--r--fe/src/lib/Card.svelte23
-rw-r--r--fe/src/lib/Chart.svelte63
-rw-r--r--fe/src/lib/Column.svelte13
-rw-r--r--fe/src/lib/DataView.svelte228
-rw-r--r--fe/src/lib/Layout.svelte74
-rw-r--r--fe/src/lib/LoginForm.svelte86
-rw-r--r--fe/src/lib/PreferencesForm.svelte119
-rw-r--r--fe/src/lib/Table.svelte114
-rw-r--r--fe/src/lib/forms/AddForm.svelte78
-rw-r--r--fe/src/main.ts8
-rw-r--r--fe/src/stores/auth.ts90
-rw-r--r--fe/src/stores/forms.ts6
-rw-r--r--fe/src/types.ts53
-rw-r--r--fe/src/utils.ts14
-rw-r--r--fe/src/vite-env.d.ts2
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
18a {
19 font-weight: 500;
20 color: #646cff;
21 text-decoration: inherit;
22}
23
24a:hover {
25 color: #535bf2;
26}
27
28body {
29 margin: 0;
30 display: flex;
31 place-items: center;
32 min-width: 320px;
33 min-height: 100vh;
34}
35
36h1 {
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
51button {
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
63button:hover {
64 border-color: #646cff;
65}
66
67button:focus,
68button: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 @@
1export 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 @@
1let instance;
2const baseUrl = import.meta.env?.VITE_API_BASE_URL ?? "http://localhost:8080/api/v1";
3
4class 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
83interface IHttpParameters {
84 endpoint: string;
85 body: Record<string, any>;
86 authenticated: boolean;
87 headers: Headers;
88}
89
90let http: Readonly<HttpClient> = Object.freeze(new HttpClient(baseUrl));
91
92export 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">
2import { onDestroy } from "svelte";
3import ChartJS from "chart.js/auto";
4
5export let data;
6export let labels;
7export let type = 'bar';
8
9let ref: HTMLCanvasElement;
10let chart
11
12function 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 @@
1import './app.css'
2import App from './App.svelte'
3
4const app = new App({
5 target: document.getElementById('app') as HTMLElement,
6})
7
8export 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 @@
1import type { Preference, TokenStore, Nullable, UserStore, User, PreferenceStore } from "../types";
2import { writable, derived } from "svelte/store";
3
4
5function 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
30function onTokenChange($token: Nullable<string>): boolean {
31 return $token ? true : false;
32}
33
34function 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
57function 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
87export const token = createTokenStore();
88export const authenticated = derived(token, onTokenChange);
89export const user = createUserStore();
90export 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 @@
1import type { Writable } from "svelte/store";
2import { writable } from "svelte/store";
3
4
5export const preferencesFormOpen: Writable<boolean> = writable<boolean>(false);
6export 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 @@
1import type { Invalidator, Subscriber, Unsubscriber, Updater } from "svelte/store";
2
3export interface Preference {
4 id: number;
5 color: string;
6 size_id: number;
7 user_id: number;
8}
9
10export interface Size {
11 size: number;
12 unit: string;
13}
14
15export interface User {
16 id: number;
17 name: string;
18 uuid: string;
19}
20
21export interface Statistic {
22 user_id: string;
23 date: string;
24 quantity: number;
25}
26
27export type Nullable<T> = T | null;
28
29export interface User {
30 uuid: string;
31 username: string;
32}
33
34export interface TokenStore {
35 subscribe: (run: Subscriber<Nullable<string>>, invalidate?: Invalidator<Nullable<string>>) => Unsubscriber,
36 authenticate: (newToken: string) => void,
37 unauthenticate: () => void
38}
39
40
41export interface UserStore {
42 subscribe: (run: Subscriber<Nullable<User>>, invalidate?: Invalidator<Nullable<User>>) => Unsubscriber,
43 setUser: (user: User) => void,
44 reset: () => void
45}
46
47export 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 @@
1export 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
11export 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" />