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" /> | ||