diff options
| author | Zach Berwaldt <zberwaldt@tutamail.com> | 2024-03-02 16:52:55 -0500 |
|---|---|---|
| committer | Zach Berwaldt <zberwaldt@tutamail.com> | 2024-03-02 16:52:55 -0500 |
| commit | cf2113e77edabf8e3a632c7b76c769752039ba88 (patch) | |
| tree | 874872f22aa63df532769de62119816748b167f8 | |
| parent | 326f186d67017f87e631a1fbcdf3f184cbc42d7d (diff) | |
feat: Add API logging
Add logging to the API to keep track of specific requests and headers. Also added CORS middleware to handle OPTIONS requests.
---
The commit adds logging functionality to the API and includes a middleware function to handle CORS OPTIONS requests. This will allow us to track specific requests and headers received by the API.
[API/main.go](/api/main.go):
- Added import for the 'log' package
- Added logging statements to print the request headers and "_I am here_" message
- Removed unnecessary newlines and comments
[fe/src/app.css](/fe/src/app.css):
- Added a new style for button hover effects
[fe/src/lib/Card.svelte](/fe/src/lib/Card.svelte):
- Added a new `height` prop to the Card component
[fe/src/lib/Column.svelte](/fe/src/lib/Column.svelte):
- Added a new CSS class for column layout
- Set the width and gap using CSS variables
[fe/src/lib/DataView.svelte](/fe/src/lib/DataView.svelte):
- Updated the 'fetchData' function to also fetch 'totals' and 'userStats' data
- Added canvas references and chart variables for bar and line charts
- Added a new 'getLastSevenDays' function to calculate the labels for the charts
- Updated the 'onMount' function to initialize the bar and line charts using the canvas references and data
- Updated the 'onDestroy' function to destroy the bar and line charts
- Added a new 'addFormOpen' store and imported it
- Added a new 'onClick' handler for the Add button to open the AddForm modal
- Updated the layout and added Card components to display the bar and line charts and the JSON data
- Added a new 'fetchTotals' function to fetch data for the 'totals' section
- Added a new 'fetchStatsForUser' function to fetch data for the 'userStats' section
[fe/src/lib/Layout.svelte](/fe/src/lib/Layout.svelte):
- Added a new 'preferenceFormOpen' variable and initialized it to 'false'
- Added a new 'showPreferencesDialog' function to set 'preferenceFormOpen' to 'true'
- Added a new 'closePreferenceDialog' function to set 'preferenceFormOpen' to 'false'
- Added a new 'showAddDialog' function to open the AddForm modal
| -rw-r--r-- | api/main.go | 384 | ||||
| -rw-r--r-- | fe/src/app.css | 101 | ||||
| -rw-r--r-- | fe/src/lib/Card.svelte | 30 | ||||
| -rw-r--r-- | fe/src/lib/Column.svelte | 13 | ||||
| -rw-r--r-- | fe/src/lib/DataView.svelte | 129 | ||||
| -rw-r--r-- | fe/src/lib/Layout.svelte | 116 | ||||
| -rw-r--r-- | fe/src/lib/Table.svelte | 172 | ||||
| -rw-r--r-- | fe/src/stores/forms.ts | 6 |
8 files changed, 574 insertions, 377 deletions
diff --git a/api/main.go b/api/main.go index 91b7929..57feb09 100644 --- a/api/main.go +++ b/api/main.go | |||
| @@ -2,193 +2,267 @@ package main | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "net/http" | 4 | "net/http" |
| 5 | "crypto/rand" | 5 | "crypto/rand" |
| 6 | "encoding/base64" | 6 | "encoding/base64" |
| 7 | "database/sql" | 7 | "database/sql" |
| 8 | "strings" | 8 | "strings" |
| 9 | "errors" | 9 | "errors" |
| 10 | "log" | ||
| 10 | 11 | ||
| 11 | "github.com/gin-gonic/gin" | 12 | "github.com/gin-gonic/gin" |
| 12 | _ "github.com/mattn/go-sqlite3" | 13 | _ "github.com/mattn/go-sqlite3" |
| 13 | "golang.org/x/crypto/bcrypt" | 14 | "golang.org/x/crypto/bcrypt" |
| 14 | "water/api/lib" | 15 | "water/api/lib" |
| 15 | ) | 16 | ) |
| 16 | 17 | ||
| 17 | func CORSMiddleware() gin.HandlerFunc { | 18 | func CORSMiddleware() gin.HandlerFunc { |
| 18 | return func(c *gin.Context) { | 19 | return func(c *gin.Context) { |
| 19 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*") | 20 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*") |
| 20 | c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") | 21 | c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") |
| 21 | c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") | 22 | c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") |
| 22 | c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") | 23 | c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") |
| 23 | 24 | ||
| 24 | if c.Request.Method == "OPTIONS" { | 25 | log.Println("I am here") |
| 25 | c.AbortWithStatus(204) | 26 | |
| 26 | return | 27 | if c.Request.Method == "OPTIONS" { |
| 27 | } | 28 | log.Println(c.Request.Header) |
| 28 | 29 | c.AbortWithStatus(204) | |
| 29 | c.Next() | 30 | return |
| 30 | } | 31 | } |
| 32 | |||
| 33 | log.Println(c.Request.Header) | ||
| 34 | c.Next() | ||
| 35 | } | ||
| 31 | } | 36 | } |
| 32 | 37 | ||
| 33 | // generatToken will g | 38 | // generatToken will g |
| 34 | func generateToken() string { | 39 | func generateToken() string { |
| 35 | token := make([]byte, 32) | 40 | token := make([]byte, 32) |
| 36 | rand.Read(token) | 41 | rand.Read(token) |
| 37 | return base64.StdEncoding.EncodeToString(token) | 42 | return base64.StdEncoding.EncodeToString(token) |
| 38 | } | 43 | } |
| 39 | 44 | ||
| 40 | func establishDBConnection() *sql.DB { | 45 | func establishDBConnection() *sql.DB { |
| 41 | db, err := sql.Open("sqlite3", "../db/water.sqlite3") | 46 | db, err := sql.Open("sqlite3", "../db/water.sqlite3") |
| 42 | if err != nil { | 47 | if err != nil { |
| 43 | panic(err) | 48 | panic(err) |
| 44 | } | 49 | } |
| 45 | return db | 50 | return db |
| 46 | } | 51 | } |
| 47 | 52 | ||
| 48 | |||
| 49 | func checkForTokenInContext(c *gin.Context) (string, error) { | 53 | func checkForTokenInContext(c *gin.Context) (string, error) { |
| 50 | authorizationHeader := c.GetHeader("Authorization") | 54 | authorizationHeader := c.GetHeader("Authorization") |
| 51 | if authorizationHeader == "" { | 55 | if authorizationHeader == "" { |
| 52 | return "", errors.New("Authorization header is missing") | 56 | return "", errors.New("Authorization header is missing") |
| 53 | } | 57 | } |
| 54 | 58 | ||
| 55 | parts := strings.Split(authorizationHeader, " ") | 59 | parts := strings.Split(authorizationHeader, " ") |
| 56 | 60 | ||
| 57 | if len(parts) != 2 || parts[0] != "Bearer" { | 61 | if len(parts) != 2 || parts[0] != "Bearer" { |
| 58 | return "", errors.New("Invalid Authorization header format") | 62 | return "", errors.New("Invalid Authorization header format") |
| 59 | } | 63 | } |
| 60 | 64 | ||
| 61 | 65 | return parts[1], nil | |
| 62 | return parts[1], nil | ||
| 63 | } | 66 | } |
| 64 | 67 | ||
| 65 | |||
| 66 | func TokenRequired() gin.HandlerFunc { | 68 | func TokenRequired() gin.HandlerFunc { |
| 67 | return func(c *gin.Context) { | 69 | return func(c *gin.Context) { |
| 68 | _, err := checkForTokenInContext(c) | 70 | _, err := checkForTokenInContext(c) |
| 69 | 71 | ||
| 70 | if err != nil { | 72 | if err != nil { |
| 71 | c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) | 73 | c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) |
| 72 | c.Abort() | 74 | c.Abort() |
| 73 | return | 75 | return |
| 74 | } | 76 | } |
| 75 | 77 | ||
| 76 | c.Next() | 78 | c.Next() |
| 77 | } | 79 | } |
| 78 | } | 80 | } |
| 79 | 81 | ||
| 80 | func setupRouter() *gin.Engine { | 82 | func setupRouter() *gin.Engine { |
| 81 | // Disable Console Color | 83 | // Disable Console Color |
| 82 | // gin.DisableConsoleColor() | 84 | // gin.DisableConsoleColor() |
| 83 | r := gin.Default() | 85 | r := gin.Default() |
| 84 | r.Use(CORSMiddleware()) | 86 | r.Use(CORSMiddleware()) |
| 85 | r.Use(gin.Logger()) | 87 | r.Use(gin.Logger()) |
| 86 | r.Use(gin.Recovery()) | 88 | r.Use(gin.Recovery()) |
| 87 | 89 | ||
| 88 | api := r.Group("api/v1") | 90 | api := r.Group("api/v1") |
| 89 | 91 | ||
| 90 | api.POST("/auth", func(c *gin.Context) { | 92 | api.POST("/auth", func(c *gin.Context) { |
| 91 | username, password, ok := c.Request.BasicAuth() | 93 | username, password, ok := c.Request.BasicAuth() |
| 92 | if !ok { | 94 | if !ok { |
| 93 | c.Header("WWW-Authenticate", `Basic realm="Please enter your username and password."`) | 95 | c.Header("WWW-Authenticate", `Basic realm="Please enter your username and password."`) |
| 94 | c.AbortWithStatus(http.StatusUnauthorized) | 96 | c.AbortWithStatus(http.StatusUnauthorized) |
| 95 | return | 97 | return |
| 96 | } | 98 | } |
| 97 | 99 | ||
| 98 | db := establishDBConnection() | 100 | db := establishDBConnection() |
| 99 | defer db.Close() | 101 | defer db.Close() |
| 100 | 102 | ||
| 101 | var user models.User | 103 | var user models.User |
| 102 | var preference models.Preference | 104 | var preference models.Preference |
| 103 | var size models.Size | 105 | var size models.Size |
| 104 | 106 | ||
| 105 | row := db.QueryRow("SELECT name, uuid, password, color, size, unit FROM Users u INNER JOIN Preferences p ON p.user_id = u.id INNER JOIN Sizes s ON p.size_id = s.id WHERE u.name = ?", username) | 107 | row := db.QueryRow("SELECT name, uuid, password, color, size, unit FROM Users u INNER JOIN Preferences p ON p.user_id = u.id INNER JOIN Sizes s ON p.size_id = s.id WHERE u.name = ?", username) |
| 106 | if err := row.Scan(&user.Name, &user.UUID, &user.Password, &preference.Color, &size.Size, &size.Unit); err != nil { | 108 | if err := row.Scan(&user.Name, &user.UUID, &user.Password, &preference.Color, &size.Size, &size.Unit); err != nil { |
| 107 | if err == sql.ErrNoRows { | 109 | if err == sql.ErrNoRows { |
| 108 | c.AbortWithStatus(http.StatusUnauthorized) | 110 | c.AbortWithStatus(http.StatusUnauthorized) |
| 109 | return | 111 | return |
| 110 | } | 112 | } |
| 111 | } | 113 | } |
| 112 | 114 | ||
| 113 | if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { | 115 | if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { |
| 114 | c.AbortWithStatus(http.StatusUnauthorized) | 116 | c.AbortWithStatus(http.StatusUnauthorized) |
| 115 | return | 117 | return |
| 116 | } | 118 | } |
| 117 | 119 | ||
| 118 | preference.Size = size | 120 | preference.Size = size |
| 119 | 121 | ||
| 120 | // Generate a simple API token | 122 | // Generate a simple API token |
| 121 | apiToken := generateToken() | 123 | apiToken := generateToken() |
| 122 | c.JSON(http.StatusOK, gin.H{"token": apiToken, "user": user, "preferences": preference}) | 124 | c.JSON(http.StatusOK, gin.H{"token": apiToken, "user": user, "preferences": preference}) |
| 123 | }) | 125 | }) |
| 124 | 126 | ||
| 125 | stats := api.Group("stats") | 127 | stats := api.Group("/stats") |
| 126 | stats.Use(TokenRequired()) | 128 | stats.Use(TokenRequired()) |
| 127 | { | 129 | { |
| 128 | stats.GET("/", func(c *gin.Context) { | 130 | stats.GET("/", func(c *gin.Context) { |
| 129 | db := establishDBConnection() | 131 | db := establishDBConnection() |
| 130 | defer db.Close() | 132 | defer db.Close() |
| 131 | 133 | ||
| 132 | rows, err := db.Query("SELECT s.date, s.quantity, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id"); | 134 | rows, err := db.Query("SELECT s.date, s.quantity, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id") |
| 133 | if err != nil { | 135 | if err != nil { |
| 134 | c.JSON(500, gin.H{"error": err.Error()}) | 136 | c.JSON(500, gin.H{"error": err.Error()}) |
| 135 | return | 137 | return |
| 136 | } | 138 | } |
| 137 | defer rows.Close() | 139 | defer rows.Close() |
| 138 | 140 | ||
| 139 | var data []models.Statistic | 141 | var data []models.Statistic |
| 140 | for rows.Next() { | 142 | |
| 141 | var stat models.Statistic | 143 | for rows.Next() { |
| 142 | var user models.User | 144 | var stat models.Statistic |
| 143 | if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil { | 145 | var user models.User |
| 144 | c.JSON(500, gin.H{"error": err.Error()}) | 146 | if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil { |
| 145 | return | 147 | c.JSON(500, gin.H{"error": err.Error()}) |
| 146 | } | 148 | return |
| 147 | stat.User = user | 149 | } |
| 148 | data = append(data, stat) | 150 | stat.User = user |
| 149 | } | 151 | data = append(data, stat) |
| 150 | 152 | } | |
| 151 | c.JSON(http.StatusOK, data) | 153 | |
| 152 | }) | 154 | |
| 153 | 155 | // TODO: return to this and figure out how to best collect the data you are looking for for each user (zach and parker) | |
| 154 | stats.POST("/", func(c *gin.Context) { | 156 | rows, err = db.Query("SELECT date(s.date), SUM(s.quantity) as total FROM Statistics s WHERE s.date >= date('now', '-7 days') GROUP BY DATE(s.date)") |
| 155 | var stat models.Statistic | 157 | if err != nil { |
| 156 | 158 | c.JSON(500, gin.H{"error": err.Error()}) | |
| 157 | if err := c.BindJSON(&stat); err != nil { | 159 | return |
| 158 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | 160 | } |
| 159 | return | 161 | defer rows.Close() |
| 160 | } | 162 | |
| 161 | 163 | var dailySummaries []models.DailySummary | |
| 162 | db := establishDBConnection() | 164 | for rows.Next() { |
| 163 | defer db.Close() | 165 | var summary models.DailySummary |
| 164 | 166 | if err := rows.Scan(&summary.Date, &summary.Total); err != nil { | |
| 165 | result, err := db.Exec("INSERT INTO statistics (date, user_id, quantity) values (?, ?, ?)", stat.Date, 1, stat.Quantity) | 167 | c.JSON(500, gin.H{"error": err.Error()}) |
| 166 | 168 | return | |
| 167 | if err != nil { | 169 | } |
| 168 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | 170 | dailySummaries = append(dailySummaries, summary) |
| 169 | } | 171 | } |
| 170 | 172 | ||
| 171 | id, err := result.LastInsertId() | 173 | c.JSON(http.StatusOK, gin.H{"stats": data, "totals": dailySummaries}) |
| 172 | if err != nil { | 174 | rows, err = db.Query("SELECT s.date, SUM(s.quantity) as total, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id WHERE s.date >= date('now', '-7 days') GROUP BY s.date, s.user_id") |
| 173 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | 175 | |
| 174 | } | 176 | if err != nil { |
| 175 | 177 | c.JSON(500, gin.H{"error": err.Error()}) | |
| 176 | c.JSON(http.StatusCreated, gin.H{"status": "created", "id": id}) | 178 | return |
| 177 | }) | 179 | } |
| 178 | 180 | defer rows.Close() | |
| 179 | stats.GET("/:uuid", func(c *gin.Context) { | 181 | |
| 180 | c.JSON(http.StatusOK, gin.H{"status": "ok", "uuid": c.Param("uuid")}) | 182 | var totals []interface{} |
| 181 | }) | 183 | for rows.Next() { |
| 182 | 184 | var stat models.Statistic | |
| 183 | stats.PATCH("/:uuid", func(c *gin.Context) { | 185 | var user models.User |
| 184 | c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) | 186 | if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil { |
| 185 | }) | 187 | c.JSON(500, gin.H{"error": err.Error()}) |
| 186 | 188 | return | |
| 187 | stats.DELETE("/:uuid", func(c *gin.Context) { | 189 | } |
| 188 | c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) | 190 | stat.User = user |
| 189 | }) | 191 | totals = append(totals, stat) |
| 190 | } | 192 | } |
| 191 | 193 | ||
| 194 | c.JSON(http.StatusOK, gin.H{"stats": data, "totals": totals}) | ||
| 195 | }) | ||
| 196 | |||
| 197 | stats.POST("/", func(c *gin.Context) { | ||
| 198 | var stat models.Statistic | ||
| 199 | |||
| 200 | if err := c.BindJSON(&stat); err != nil { | ||
| 201 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| 202 | return | ||
| 203 | } | ||
| 204 | |||
| 205 | db := establishDBConnection() | ||
| 206 | defer db.Close() | ||
| 207 | |||
| 208 | result, err := db.Exec("INSERT INTO statistics (date, user_id, quantity) values (?, ?, ?)", stat.Date, 1, stat.Quantity) | ||
| 209 | |||
| 210 | if err != nil { | ||
| 211 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| 212 | } | ||
| 213 | |||
| 214 | id, err := result.LastInsertId() | ||
| 215 | if err != nil { | ||
| 216 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| 217 | } | ||
| 218 | |||
| 219 | c.JSON(http.StatusCreated, gin.H{"status": "created", "id": id}) | ||
| 220 | }) | ||
| 221 | |||
| 222 | stats.GET("/totals/", func(c *gin.Context) { | ||
| 223 | c.JSON(http.StatusOK, gin.H{"status": "ok"}) | ||
| 224 | }) | ||
| 225 | |||
| 226 | // stats.GET("/totals/", func(c *gin.Context) { | ||
| 227 | // db := establishDBConnection() | ||
| 228 | // defer db.Close() | ||
| 229 | // | ||
| 230 | // rows, err := db.Query("SELECT s.date, SUM(s.quantity) as total, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id WHERE s.date >= date('now', '-7 days') GROUP BY s.date, s.user_id") | ||
| 231 | // | ||
| 232 | // if err != nil { | ||
| 233 | // c.JSON(500, gin.H{"error": err.Error()}) | ||
| 234 | // return | ||
| 235 | // } | ||
| 236 | // defer rows.Close() | ||
| 237 | // | ||
| 238 | // var data []models.Statistic | ||
| 239 | // for rows.Next() { | ||
| 240 | // var stat models.Statistic | ||
| 241 | // var user models.User | ||
| 242 | // if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil { | ||
| 243 | // c.JSON(500, gin.H{"error": err.Error()}) | ||
| 244 | // return | ||
| 245 | // } | ||
| 246 | // stat.User = user | ||
| 247 | // data = append(data, stat) | ||
| 248 | // } | ||
| 249 | // | ||
| 250 | // c.JSON(http.StatusOK, data) | ||
| 251 | // | ||
| 252 | // }) | ||
| 253 | |||
| 254 | stats.GET("user/:uuid", func(c *gin.Context) { | ||
| 255 | c.JSON(http.StatusOK, gin.H{"status": "ok", "uuid": c.Param("uuid")}) | ||
| 256 | }) | ||
| 257 | |||
| 258 | stats.PATCH("user/:uuid", func(c *gin.Context) { | ||
| 259 | c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) | ||
| 260 | }) | ||
| 261 | |||
| 262 | stats.DELETE("user/:uuid", func(c *gin.Context) { | ||
| 263 | c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) | ||
| 264 | }) | ||
| 265 | } | ||
| 192 | 266 | ||
| 193 | return r | 267 | return r |
| 194 | } | 268 | } |
diff --git a/fe/src/app.css b/fe/src/app.css index 0d5fa90..de19b52 100644 --- a/fe/src/app.css +++ b/fe/src/app.css | |||
| @@ -1,82 +1,87 @@ | |||
| 1 | :root { | 1 | :root { |
| 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; | 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; |
| 3 | line-height: 1.5; | 3 | line-height: 1.5; |
| 4 | font-weight: 400; | 4 | font-weight: 400; |
| 5 | 5 | ||
| 6 | color-scheme: light dark; | 6 | color-scheme: light dark; |
| 7 | color: rgba(255, 255, 255, 0.87); | 7 | color: rgba(255, 255, 255, 0.87); |
| 8 | background-color: #242424; | 8 | background-color: #242424; |
| 9 | 9 | ||
| 10 | font-synthesis: none; | 10 | font-synthesis: none; |
| 11 | text-rendering: optimizeLegibility; | 11 | text-rendering: optimizeLegibility; |
| 12 | -webkit-font-smoothing: antialiased; | 12 | -webkit-font-smoothing: antialiased; |
| 13 | -moz-osx-font-smoothing: grayscale; | 13 | -moz-osx-font-smoothing: grayscale; |
| 14 | 14 | ||
| 15 | --submit: #28a745; | 15 | --submit: #28a745; |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | a { | 18 | a { |
| 19 | font-weight: 500; | 19 | font-weight: 500; |
| 20 | color: #646cff; | 20 | color: #646cff; |
| 21 | text-decoration: inherit; | 21 | text-decoration: inherit; |
| 22 | } | 22 | } |
| 23 | |||
| 23 | a:hover { | 24 | a:hover { |
| 24 | color: #535bf2; | 25 | color: #535bf2; |
| 25 | } | 26 | } |
| 26 | 27 | ||
| 27 | body { | 28 | body { |
| 28 | margin: 0; | 29 | margin: 0; |
| 29 | display: flex; | 30 | display: flex; |
| 30 | place-items: center; | 31 | place-items: center; |
| 31 | min-width: 320px; | 32 | min-width: 320px; |
| 32 | min-height: 100vh; | 33 | min-height: 100vh; |
| 33 | } | 34 | } |
| 34 | 35 | ||
| 35 | h1 { | 36 | h1 { |
| 36 | font-size: 3.2em; | 37 | font-size: 3.2em; |
| 37 | line-height: 1.1; | 38 | line-height: 1.1; |
| 38 | } | 39 | } |
| 39 | 40 | ||
| 40 | .card { | 41 | .card { |
| 41 | padding: 2em; | 42 | padding: 2em; |
| 42 | } | 43 | } |
| 43 | 44 | ||
| 44 | #app { | 45 | #app { |
| 45 | flex-grow: 2; | 46 | flex-grow: 2; |
| 46 | max-width: 1280px; | 47 | max-width: 1280px; |
| 47 | margin: 0 auto; | 48 | margin: 0 auto; |
| 48 | } | 49 | } |
| 49 | 50 | ||
| 50 | button { | 51 | button { |
| 51 | border-radius: 8px; | 52 | border-radius: 8px; |
| 52 | border: 1px solid transparent; | 53 | border: 1px solid transparent; |
| 53 | padding: 0.6em 1.2em; | 54 | padding: 0.6em 1.2em; |
| 54 | font-size: 1em; | 55 | font-size: 1em; |
| 55 | font-weight: 500; | 56 | font-weight: 500; |
| 56 | font-family: inherit; | 57 | font-family: inherit; |
| 57 | background-color: #1a1a1a; | 58 | background-color: #1a1a1a; |
| 58 | cursor: pointer; | 59 | cursor: pointer; |
| 59 | transition: border-color 0.25s; | 60 | transition: border-color 0.25s; |
| 60 | } | 61 | } |
| 62 | |||
| 61 | button:hover { | 63 | button:hover { |
| 62 | border-color: #646cff; | 64 | border-color: #646cff; |
| 63 | } | 65 | } |
| 66 | |||
| 64 | button:focus, | 67 | button:focus, |
| 65 | button:focus-visible { | 68 | button:focus-visible { |
| 66 | outline: 4px auto -webkit-focus-ring-color; | 69 | outline: 4px auto -webkit-focus-ring-color; |
| 67 | } | 70 | } |
| 68 | 71 | ||
| 69 | @media (prefers-color-scheme: light) { | 72 | @media (prefers-color-scheme: light) { |
| 70 | :root { | 73 | :root { |
| 71 | color: #213547; | 74 | color: #213547; |
| 72 | background-color: #ffffff; | 75 | background-color: #ffffff; |
| 73 | } | 76 | } |
| 74 | a:hover { | 77 | |
| 75 | color: #747bff; | 78 | a:hover { |
| 76 | } | 79 | color: #747bff; |
| 77 | button { | 80 | } |
| 78 | background-color: #f9f9f9; | 81 | |
| 79 | } | 82 | button { |
| 83 | background-color: #f9f9f9; | ||
| 84 | } | ||
| 80 | } | 85 | } |
| 81 | 86 | ||
| 82 | @media (prefers-color-scheme: dark) { | 87 | @media (prefers-color-scheme: dark) { |
| @@ -97,7 +102,7 @@ button:focus-visible { | |||
| 97 | } | 102 | } |
| 98 | 103 | ||
| 99 | .form.input.group label { | 104 | .form.input.group label { |
| 100 | margin-bottom: .5em; | 105 | margin-bottom: .5em; |
| 101 | } | 106 | } |
| 102 | 107 | ||
| 103 | .form.input.group input { | 108 | .form.input.group input { |
diff --git a/fe/src/lib/Card.svelte b/fe/src/lib/Card.svelte index 0835940..d7cd900 100644 --- a/fe/src/lib/Card.svelte +++ b/fe/src/lib/Card.svelte | |||
| @@ -1,22 +1,24 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | export let title = ""; | 2 | export let title = ""; |
| 3 | export let height: number | undefined = undefined; | ||
| 3 | </script> | 4 | </script> |
| 4 | 5 | ||
| 5 | <div class="card"> | 6 | <div class="card"> |
| 6 | {#if title} | 7 | {#if title} |
| 7 | <h2>{title}</h2> | 8 | <h2>{title}</h2> |
| 8 | {/if} | 9 | {/if} |
| 9 | <slot /> | 10 | <slot /> |
| 10 | </div> | 11 | </div> |
| 11 | 12 | ||
| 12 | <style> | 13 | <style> |
| 13 | .card { | 14 | .card { |
| 14 | background: #fff; | 15 | background: #fff; |
| 15 | width: 16rem; | 16 | display: flex; |
| 16 | display: flex; | 17 | flex-direction: column; |
| 17 | flex-direction: column; | 18 | align-items: flex-start; |
| 18 | align-items: left; | 19 | border: solid 2px #00000066; |
| 19 | border: solid 2px #00000066; | 20 | border-radius: 0.25em; |
| 20 | border-radius: 0.25em; | 21 | height: var(--height, fit-content); |
| 21 | } | 22 | overflow-y: var(--overflow, initial); |
| 23 | } | ||
| 22 | </style> | 24 | </style> |
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 index 5182a85..2b1b8b9 100644 --- a/fe/src/lib/DataView.svelte +++ b/fe/src/lib/DataView.svelte | |||
| @@ -1,16 +1,21 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import { onDestroy, onMount } from "svelte"; | 2 | import { onDestroy, onMount } from "svelte"; |
| 3 | import { token } from "../stores/auth"; | 3 | import { token } from "../stores/auth"; |
| 4 | import { addFormOpen } from "../stores/forms"; | ||
| 4 | import Table from "./Table.svelte"; | 5 | import Table from "./Table.svelte"; |
| 5 | import Chart from "chart.js/auto"; | 6 | import Chart from "chart.js/auto"; |
| 7 | import Card from "./Card.svelte"; | ||
| 8 | import Column from "./Column.svelte"; | ||
| 6 | import AddForm from "./forms/AddForm.svelte"; | 9 | import AddForm from "./forms/AddForm.svelte"; |
| 7 | 10 | ||
| 8 | let open: boolean = false; | ||
| 9 | |||
| 10 | let json: Promise<any>; | 11 | let json: Promise<any>; |
| 12 | let totals: Promise<any>; | ||
| 13 | let userStats: Promise<any>; | ||
| 11 | 14 | ||
| 12 | let canvasRef: HTMLCanvasElement; | 15 | let barCanvasRef: HTMLCanvasElement; |
| 13 | let chart: any; | 16 | let lineCanvasRef: HTMLCanvasElement; |
| 17 | let barChart: any; | ||
| 18 | let lineChart: any; | ||
| 14 | 19 | ||
| 15 | let lastSevenDays: string[]; | 20 | let lastSevenDays: string[]; |
| 16 | 21 | ||
| @@ -28,6 +33,37 @@ | |||
| 28 | } | 33 | } |
| 29 | } | 34 | } |
| 30 | 35 | ||
| 36 | async function fetchTotals() { | ||
| 37 | const res = await fetch("http://localhost:8080/api/v1/stats/totals/", { | ||
| 38 | method: 'GET', | ||
| 39 | mode: 'no-cors', | ||
| 40 | headers: { | ||
| 41 | Authorization: `Bearer ${$token}` | ||
| 42 | } | ||
| 43 | }); | ||
| 44 | |||
| 45 | if (res.ok) { | ||
| 46 | totals = res.json(); | ||
| 47 | } else { | ||
| 48 | throw new Error("There was a problem with your request"); | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | async function fetchStatsForUser() { | ||
| 53 | const res = await fetch("http://localhost:8080/api/v1/stats/user/1aa668f3-7527-4a67-9c24-fdf307542eeb", { | ||
| 54 | method: "GET", | ||
| 55 | headers: { | ||
| 56 | Authorization: `Bearer ${$token}` | ||
| 57 | } | ||
| 58 | }); | ||
| 59 | |||
| 60 | if (res.ok) { | ||
| 61 | userStats = res.json(); | ||
| 62 | } else { | ||
| 63 | throw new Error("There was a problem with your request"); | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 31 | function getLastSevenDays() { | 67 | function getLastSevenDays() { |
| 32 | const result = []; | 68 | const result = []; |
| 33 | for (let i = 0; i < 7; i++) { | 69 | for (let i = 0; i < 7; i++) { |
| @@ -38,23 +74,21 @@ | |||
| 38 | return result; | 74 | return result; |
| 39 | } | 75 | } |
| 40 | 76 | ||
| 41 | function handleClick() { | ||
| 42 | open = true; | ||
| 43 | } | ||
| 44 | |||
| 45 | function closeDialog() { | 77 | function closeDialog() { |
| 46 | open = false; | 78 | addFormOpen.set(false); |
| 47 | } | 79 | } |
| 48 | 80 | ||
| 49 | function onStatisticAdd() { | 81 | function onStatisticAdd() { |
| 50 | open = false; | 82 | closeDialog(); |
| 51 | fetchData(); | 83 | fetchData(); |
| 52 | } | 84 | } |
| 53 | 85 | ||
| 54 | onMount(() => { | 86 | onMount(() => { |
| 55 | fetchData(); | 87 | fetchData(); |
| 88 | // fetchTotals(); | ||
| 89 | fetchStatsForUser(); | ||
| 56 | lastSevenDays = getLastSevenDays(); | 90 | lastSevenDays = getLastSevenDays(); |
| 57 | chart = new Chart(canvasRef, { | 91 | barChart = new Chart(barCanvasRef, { |
| 58 | type: "bar", | 92 | type: "bar", |
| 59 | data: { | 93 | data: { |
| 60 | labels: lastSevenDays, | 94 | labels: lastSevenDays, |
| @@ -73,27 +107,74 @@ | |||
| 73 | borderWidth: 1 | 107 | borderWidth: 1 |
| 74 | } | 108 | } |
| 75 | ] | 109 | ] |
| 110 | }, | ||
| 111 | options: { | ||
| 112 | responsive: true | ||
| 113 | } | ||
| 114 | }); | ||
| 115 | lineChart = new Chart(lineCanvasRef, { | ||
| 116 | type: "line", | ||
| 117 | data: { | ||
| 118 | labels: lastSevenDays, | ||
| 119 | datasets: [ | ||
| 120 | { | ||
| 121 | label: "Zach", | ||
| 122 | data: [1, 2, 8, 2, 5, 5, 1], | ||
| 123 | backgroundColor: "rgba(255, 192, 192, 0.2)", | ||
| 124 | borderColor: "rgba(75, 192, 192, 1)", | ||
| 125 | borderWidth: 1 | ||
| 126 | }, { | ||
| 127 | label: "Parker", | ||
| 128 | data: [6, 1, 1, 4, 3, 5, 1], | ||
| 129 | backgroundColor: "rgba(75, 192, 192, 0.2)", | ||
| 130 | borderColor: "rgba(75, 192, 192, 1)", | ||
| 131 | borderWidth: 1 | ||
| 132 | } | ||
| 133 | ] | ||
| 134 | }, | ||
| 135 | options: { | ||
| 136 | responsive: true | ||
| 76 | } | 137 | } |
| 77 | }); | 138 | }); |
| 78 | }); | 139 | }); |
| 79 | 140 | ||
| 80 | onDestroy(() => { | 141 | onDestroy(() => { |
| 81 | if (chart) chart.destroy(); | 142 | if (barChart) barChart.destroy(); |
| 82 | chart = null; | 143 | if (lineChart) lineChart.destroy(); |
| 144 | barChart = null; | ||
| 145 | lineChart = null; | ||
| 83 | }); | 146 | }); |
| 84 | </script> | 147 | </script> |
| 85 | 148 | ||
| 86 | <div> | 149 | <Column --width="500px"> |
| 87 | <button on:click={handleClick}>Add</button> | 150 | <Card> |
| 88 | <AddForm {open} on:submit={onStatisticAdd} on:close={closeDialog} /> | 151 | <canvas bind:this={barCanvasRef} width="" /> |
| 89 | <canvas bind:this={canvasRef} /> | 152 | </Card> |
| 90 | {#await json then data} | 153 | <Card> |
| 91 | <Table {data} nofooter /> | 154 | <canvas bind:this={lineCanvasRef} /> |
| 92 | {:catch error} | 155 | </Card> |
| 93 | <p>{error}</p> | 156 | </Column> |
| 94 | {/await} | 157 | |
| 95 | <!-- <Chart /> --> | 158 | <AddForm open={$addFormOpen} on:submit={onStatisticAdd} on:close={closeDialog} /> |
| 96 | </div> | 159 | <Column> |
| 160 | <Card> | ||
| 161 | {#await json then data} | ||
| 162 | <Table {data} nofooter /> | ||
| 163 | {:catch error} | ||
| 164 | <p>{error}</p> | ||
| 165 | {/await} | ||
| 166 | </Card> | ||
| 167 | <Card> | ||
| 168 | <button on:click={() => fetchTotals()}>Get totals</button> | ||
| 169 | {#await totals then data} | ||
| 170 | {JSON.stringify(data)} | ||
| 171 | {:catch error} | ||
| 172 | <p>{error}</p> | ||
| 173 | {/await} | ||
| 174 | </Card> | ||
| 175 | </Column> | ||
| 176 | <!-- <Chart /> --> | ||
| 177 | |||
| 97 | 178 | ||
| 98 | <style> | 179 | <style> |
| 99 | dialog { | 180 | dialog { |
diff --git a/fe/src/lib/Layout.svelte b/fe/src/lib/Layout.svelte index 94ce84d..f208f34 100644 --- a/fe/src/lib/Layout.svelte +++ b/fe/src/lib/Layout.svelte | |||
| @@ -1,62 +1,70 @@ | |||
| 1 | <script> | 1 | <script> |
| 2 | import { authenticated, token } from "../stores/auth"; | 2 | import { authenticated, token } from "../stores/auth"; |
| 3 | import PreferencesForm from "./PreferencesForm.svelte"; | 3 | import PreferencesForm from "./PreferencesForm.svelte"; |
| 4 | const logout = () => token.unauthenticate(); | 4 | import { addFormOpen } from "../stores/forms"; |
| 5 | let open = false; | 5 | |
| 6 | 6 | const logout = () => token.unauthenticate(); | |
| 7 | function showSettingsDialog() { | 7 | let preferenceFormOpen = false; |
| 8 | open = true; | 8 | |
| 9 | } | 9 | function showPreferencesDialog() { |
| 10 | 10 | preferenceFormOpen = true; | |
| 11 | function closeDialog() { | 11 | } |
| 12 | open = false; | 12 | |
| 13 | } | 13 | function closePreferenceDialog() { |
| 14 | preferenceFormOpen = false; | ||
| 15 | } | ||
| 16 | |||
| 17 | function showAddDialog() { | ||
| 18 | addFormOpen.set(true); | ||
| 19 | } | ||
| 14 | </script> | 20 | </script> |
| 15 | 21 | ||
| 16 | <div class="layout"> | 22 | <div class="layout"> |
| 17 | {#if $authenticated} | 23 | {#if $authenticated} |
| 18 | <nav> | 24 | <nav> |
| 19 | <div> | 25 | <div> |
| 20 | <h1>Water</h1> | 26 | <h1>Water</h1> |
| 21 | </div> | 27 | </div> |
| 22 | <div> | 28 | <div> |
| 23 | <button on:click={showSettingsDialog}>Settings</button> | 29 | <button on:click={showAddDialog}>Log Water</button> |
| 24 | <button on:click={logout}>Logout</button> | 30 | <button on:click={showPreferencesDialog}>Preference</button> |
| 25 | </div> | 31 | <button on:click={logout}>Logout</button> |
| 26 | </nav> | 32 | </div> |
| 27 | <PreferencesForm {open} on:close={closeDialog} /> | 33 | </nav> |
| 28 | {/if} | 34 | <PreferencesForm open={preferenceFormOpen} on:close={closePreferenceDialog} /> |
| 29 | <div id="content"> | 35 | {/if} |
| 30 | <slot /> | 36 | <div id="content"> |
| 31 | </div> | 37 | <slot /> |
| 38 | </div> | ||
| 32 | </div> | 39 | </div> |
| 33 | 40 | ||
| 34 | <style> | 41 | <style> |
| 35 | .layout { | 42 | .layout { |
| 36 | height: 100vh; | 43 | height: 100vh; |
| 37 | } | 44 | } |
| 38 | nav { | 45 | |
| 39 | display: flex; | 46 | nav { |
| 40 | flex-direction: row; | 47 | display: flex; |
| 41 | align-items: center; | 48 | flex-direction: row; |
| 42 | justify-content: space-between; | 49 | align-items: center; |
| 43 | height: 64px; | 50 | justify-content: space-between; |
| 44 | padding: 0 2em; | 51 | height: 64px; |
| 45 | } | 52 | padding: 0 2em; |
| 46 | 53 | } | |
| 47 | nav div { | 54 | |
| 48 | width: fit-content; | 55 | nav div { |
| 49 | } | 56 | width: fit-content; |
| 50 | 57 | } | |
| 51 | nav div h1 { | 58 | |
| 52 | font-size: 1.75em; | 59 | nav div h1 { |
| 53 | } | 60 | font-size: 1.75em; |
| 54 | 61 | } | |
| 55 | #content { | 62 | |
| 56 | display: flex; | 63 | #content { |
| 57 | flex-direction: column; | 64 | display: flex; |
| 58 | justify-content: center; | 65 | flex-direction: row; |
| 59 | align-items: center; | 66 | justify-content: center; |
| 60 | padding: 3em 0; | 67 | gap: 2em; |
| 61 | } | 68 | margin-top: 4em; |
| 69 | } | ||
| 62 | </style> | 70 | </style> |
diff --git a/fe/src/lib/Table.svelte b/fe/src/lib/Table.svelte index 3a66e0d..d1cd7da 100644 --- a/fe/src/lib/Table.svelte +++ b/fe/src/lib/Table.svelte | |||
| @@ -1,105 +1,113 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | export let data: Array<any> | undefined = undefined; | 2 | export let data: Array<any> | undefined = undefined; |
| 3 | export let nofooter: boolean = false; | 3 | export let nofooter: boolean = false; |
| 4 | export let noheader: boolean = false; | 4 | export let noheader: boolean = false; |
| 5 | export let omit: string[] = ["id"]; | 5 | export let omit: string[] = ["id"]; |
| 6 | export let title: string | undefined = undefined; | 6 | export let title: string | undefined = undefined; |
| 7 | 7 | ||
| 8 | function getDataKeys(data: any[]): string[] { | 8 | function getDataKeys(data: any[]): string[] { |
| 9 | if (!data || data.length === 0) return []; | 9 | if (!data || data.length === 0) return []; |
| 10 | return Object.keys(data[0]) | 10 | return Object.keys(data[0]) |
| 11 | .map((k) => k.split("_").join(" ")) | 11 | .map((k) => k.split("_").join(" ")) |
| 12 | .filter((k) => !omit.includes(k)); | 12 | .filter((k) => !omit.includes(k)); |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | function getRow(row: Record<string, any>): Array<any> { | 15 | function getRow(row: Record<string, any>): Array<any> { |
| 16 | return Object.entries(row).filter((r) => !omit.includes(r[0])); | 16 | return Object.entries(row).filter((r) => !omit.includes(r[0])); |
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | const formatter = new Intl.DateTimeFormat("en", { | ||
| 20 | year: "numeric", | ||
| 21 | month: "numeric", | ||
| 22 | day: "numeric", | ||
| 23 | hour: "numeric", | ||
| 24 | minute: "2-digit", | ||
| 25 | second: "2-digit", | ||
| 26 | timeZone: "America/New_York", | ||
| 27 | }); | ||
| 28 | 19 | ||
| 29 | function formatDatum([key, value]: any[]) { | 20 | let limitedData: Array<any> = []; |
| 30 | if (key === "date") { | ||
| 31 | const parsedDate = new Date(value); | ||
| 32 | return formatter.format(parsedDate); | ||
| 33 | } | ||
| 34 | 21 | ||
| 35 | if (key === "user") { | 22 | if (data && (data as any[]).length > 0) { |
| 36 | return value["name"]; | 23 | limitedData = (data as any[]).slice(0, 4); |
| 37 | } | 24 | } |
| 38 | 25 | ||
| 39 | return value; | 26 | const formatter = new Intl.DateTimeFormat("en", { |
| 40 | } | 27 | year: "numeric", |
| 28 | month: "numeric", | ||
| 29 | day: "numeric", | ||
| 30 | hour: "numeric", | ||
| 31 | minute: "2-digit", | ||
| 32 | second: "2-digit", | ||
| 33 | timeZone: "America/New_York" | ||
| 34 | }); | ||
| 35 | |||
| 36 | function formatDatum([key, value]: any[]) { | ||
| 37 | if (key === "date") { | ||
| 38 | const parsedDate = new Date(value); | ||
| 39 | return formatter.format(parsedDate); | ||
| 40 | } | ||
| 41 | |||
| 42 | if (key === "user") { | ||
| 43 | return value["name"]; | ||
| 44 | } | ||
| 45 | |||
| 46 | return value; | ||
| 47 | } | ||
| 41 | </script> | 48 | </script> |
| 42 | 49 | ||
| 43 | <table> | 50 | <table> |
| 44 | {#if title} | 51 | {#if title} |
| 45 | <h2>{title}</h2> | 52 | <h2>{title}</h2> |
| 46 | {/if} | 53 | {/if} |
| 47 | {#if !noheader && data} | 54 | {#if !noheader && data} |
| 48 | <thead> | 55 | <thead> |
| 49 | <tr> | ||
| 50 | {#each getDataKeys(data) as header} | ||
| 51 | <th>{header}</th> | ||
| 52 | {/each} | ||
| 53 | </tr> | ||
| 54 | </thead> | ||
| 55 | {/if} | ||
| 56 | <tbody> | ||
| 57 | {#if data} | ||
| 58 | {#each data as row} | ||
| 59 | <tr> | 56 | <tr> |
| 60 | {#each getRow(row) as datum} | 57 | {#each getDataKeys(data) as header} |
| 61 | <td>{formatDatum(datum)}</td> | 58 | <th>{header}</th> |
| 62 | {/each} | 59 | {/each} |
| 63 | </tr> | 60 | </tr> |
| 64 | {/each} | 61 | </thead> |
| 62 | {/if} | ||
| 63 | <tbody> | ||
| 64 | {#if data} | ||
| 65 | {#each limitedData as row} | ||
| 66 | <tr> | ||
| 67 | {#each getRow(row) as datum} | ||
| 68 | <td>{formatDatum(datum)}</td> | ||
| 69 | {/each} | ||
| 70 | </tr> | ||
| 71 | {/each} | ||
| 65 | {:else} | 72 | {:else} |
| 66 | <tr> There is not data. </tr> | 73 | <tr> There is not data.</tr> |
| 74 | {/if} | ||
| 75 | </tbody> | ||
| 76 | {#if !nofooter} | ||
| 77 | <slot name="footer"> | ||
| 78 | <tfoot> | ||
| 79 | <tr> | ||
| 80 | <td>Table Footer</td> | ||
| 81 | </tr> | ||
| 82 | </tfoot> | ||
| 83 | </slot> | ||
| 67 | {/if} | 84 | {/if} |
| 68 | </tbody> | ||
| 69 | {#if !nofooter} | ||
| 70 | <slot name="footer"> | ||
| 71 | <tfoot> | ||
| 72 | <tr> | ||
| 73 | <td>Table Footer</td> | ||
| 74 | </tr> | ||
| 75 | </tfoot> | ||
| 76 | </slot> | ||
| 77 | {/if} | ||
| 78 | </table> | 85 | </table> |
| 79 | 86 | ||
| 80 | <style> | 87 | <style> |
| 81 | table { | 88 | table { |
| 82 | padding: 16px; | 89 | padding: 16px; |
| 83 | margin: 8px; | 90 | margin: 8px; |
| 84 | border: solid 1px black; | 91 | border: solid 1px black; |
| 85 | border-collapse: collapse; | 92 | border-collapse: collapse; |
| 86 | } | 93 | overflow-y: hidden; |
| 94 | } | ||
| 87 | 95 | ||
| 88 | th { | 96 | th { |
| 89 | text-transform: capitalize; | 97 | text-transform: capitalize; |
| 90 | } | 98 | } |
| 91 | 99 | ||
| 92 | thead tr { | 100 | thead tr { |
| 93 | background: rgba(0, 0, 23, 0.34); | 101 | background: rgba(0, 0, 23, 0.34); |
| 94 | } | 102 | } |
| 95 | 103 | ||
| 96 | tbody tr:nth-child(odd) { | 104 | tbody tr:nth-child(odd) { |
| 97 | background: rgba(0, 0, 23, 0.14); | 105 | background: rgba(0, 0, 23, 0.14); |
| 98 | } | 106 | } |
| 99 | 107 | ||
| 100 | th, | 108 | th, |
| 101 | td { | 109 | td { |
| 102 | padding: 1em; | 110 | padding: 1em; |
| 103 | border: 1px solid rgba(0, 0, 0, 1); | 111 | border: 1px solid rgba(0, 0, 0, 1); |
| 104 | } | 112 | } |
| 105 | </style> | 113 | </style> |
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 | ||
