A website inspired by Last.fm that will keep track of your listening statistics
lastfm music statistics
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make changes to the inertia package to support session data. Change validation package to make interacting with the inertia package easier. Add implementation of session store for inertia to the server.

oscar345 03febf81 ba2f2755

+255 -43
+1 -1
cmd/bridge/main.go
··· 36 36 return errors.New("path is required") 37 37 } 38 38 39 - router := router.New(services.ArtistService{}, services.UserService{}, nil, nil, &config.Config{}) 39 + router := router.New(services.ArtistService{}, services.UserService{}, nil, nil, nil, &config.Config{}) 40 40 bridge.CreateRoutes(router.Router(), c.path) 41 41 42 42 return nil
+1
go.mod
··· 30 30 github.com/google/flatbuffers v25.9.23+incompatible // indirect 31 31 github.com/google/uuid v1.6.0 // indirect 32 32 github.com/gorilla/securecookie v1.1.2 // indirect 33 + github.com/gorilla/sessions v1.4.0 33 34 github.com/klauspost/compress v1.18.2 // indirect 34 35 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 35 36 github.com/pierrec/lz4/v4 v4.1.22 // indirect
+2
go.sum
··· 50 50 github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 51 51 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 52 52 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 53 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 54 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 53 55 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 54 56 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 55 57 github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
+54 -4
internal/server/server.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "encoding/gob" 6 + "errors" 5 7 "html/template" 6 8 "log" 7 9 "net/http" 10 + "time" 8 11 9 12 _ "github.com/duckdb/duckdb-go/v2" 13 + "github.com/gorilla/sessions" 10 14 _ "github.com/mattn/go-sqlite3" 11 15 "github.com/oscar345/keeptrack/internal/config" 12 16 "github.com/oscar345/keeptrack/internal/image" ··· 36 40 } 37 41 38 42 func (s *Server) Start() error { 43 + now := time.Now() 39 44 generalDB := database.Open("sqlite3", s.config.MusicbrainzDatabase.Path, func(db *sql.DB) { 40 45 db.Exec("PRAGMA journal_mode = WAL") 41 46 db.Exec("PRAGMA synchronous = NORMAL") 42 47 }) 43 48 defer generalDB.Close() 44 49 50 + log.Printf("Database opened in %s", time.Since(now)) 51 + 45 52 database.AddAttachments(generalDB, map[string]string{ 46 53 "app": s.config.AppDatabase.Path, 47 54 }) 48 55 56 + log.Printf("Database opened in %s", time.Since(now)) 57 + 49 58 statisticsDB := database.Open("duckdb", s.config.StatisticsDatabase.Path, func(d *sql.DB) { 50 59 d.Exec("SET threads TO 4") 51 60 }) 52 61 defer statisticsDB.Close() 53 62 63 + log.Printf("Statistics database opened in %s", time.Since(now)) 64 + 54 65 services := s.services(generalDB, statisticsDB) 66 + store := sessions.NewCookieStore([]byte(s.config.Server.SecretKey)) 55 67 56 - inertia := setupInertia() 68 + inertia := setupInertia(store) 57 69 58 70 authenticator := authentication.NewAuthenticatorJWT(s.config.Server.SecretKey) 59 71 60 72 router := router. 61 - New(services.Artist, services.User, inertia, authenticator, s.config). 73 + New(services.Artist, services.User, inertia, authenticator, store, s.config). 62 74 Router() 75 + 76 + log.Printf("Router setup in %s", time.Since(now)) 63 77 64 78 server := http.Server{ 65 79 Addr: s.address, 66 80 Handler: router, 67 81 } 68 82 log.Printf("Listening on http://%s\n", s.address) 83 + 84 + log.Printf("Server started in %s", time.Since(now)) 69 85 70 86 return server.ListenAndServe() 71 87 } ··· 99 115 } 100 116 } 101 117 102 - func setupInertia() *inertia.Inertia { 118 + type InertiaSessionStore struct { 119 + store *sessions.CookieStore 120 + } 121 + 122 + func NewInertiaSessionStore(store *sessions.CookieStore) *InertiaSessionStore { 123 + gob.Register(inertia.SessionData{}) 124 + 125 + return &InertiaSessionStore{ 126 + store: store, 127 + } 128 + } 129 + 130 + func (is *InertiaSessionStore) Set(w http.ResponseWriter, r *http.Request, key string, value inertia.SessionData) error { 131 + session, err := is.store.Get(r, key) 132 + if err != nil { 133 + return err 134 + } 135 + session.Values["data"] = value 136 + return session.Save(r, w) 137 + } 138 + 139 + func (is *InertiaSessionStore) Get(r *http.Request, key string) (inertia.SessionData, error) { 140 + session, err := is.store.Get(r, key) 141 + if err != nil { 142 + return inertia.SessionData{}, err 143 + } 144 + data, ok := session.Values["data"].(inertia.SessionData) 145 + if !ok { 146 + return inertia.SessionData{}, errors.New("invalid session data type") 147 + } 148 + return data, nil 149 + } 150 + 151 + func setupInertia(store *sessions.CookieStore) *inertia.Inertia { 103 152 tmpl, err := template.New("root").Parse(root) 104 153 if err != nil { 105 154 log.Fatal(err) 106 155 } 107 - return inertia.New(tmpl) 156 + return inertia.New(tmpl, NewInertiaSessionStore(store)) 108 157 } 109 158 110 159 const root = /*html*/ ` ··· 113 162 <head> 114 163 <meta charset="UTF-8"> 115 164 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 165 + <meta name="referrer" content="same-origin"> 116 166 <title>Document</title> 117 167 <script src="/assets/app.js" defer type="module"></script> 118 168 <link rel="stylesheet" href="/assets/app.css">
+15
internal/web/middleware/middleware.go
··· 1 1 package middleware 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/gorilla/csrf" 7 + "github.com/oscar345/keeptrack/pkg/inertia" 8 + ) 9 + 10 + func InertiaCSRFToken(next http.Handler) http.Handler { 11 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 + token := csrf.Token(r) 13 + inertia.SetProp(r, "csrf_token", inertia.Always(token)) 14 + next.ServeHTTP(w, r) 15 + }) 16 + }
+34 -2
internal/web/router/router.go
··· 1 1 package router 2 2 3 3 import ( 4 + "encoding/json" 4 5 "net/http" 5 6 6 7 "github.com/ggicci/httpin" 7 8 "github.com/go-chi/chi/v5" 8 9 chimiddleware "github.com/go-chi/chi/v5/middleware" 9 10 "github.com/gorilla/csrf" 11 + "github.com/gorilla/sessions" 10 12 "github.com/oscar345/keeptrack/internal/config" 11 13 "github.com/oscar345/keeptrack/internal/filters" 12 14 "github.com/oscar345/keeptrack/internal/services" 13 15 "github.com/oscar345/keeptrack/internal/web/handlers" 16 + "github.com/oscar345/keeptrack/internal/web/middleware" 14 17 "github.com/oscar345/keeptrack/internal/web/requests" 15 18 "github.com/oscar345/keeptrack/internal/web/responses" 16 19 "github.com/oscar345/keeptrack/pkg/authentication" 17 20 "github.com/oscar345/keeptrack/pkg/enum" 18 21 "github.com/oscar345/keeptrack/pkg/inertia" 19 22 "github.com/oscar345/keeptrack/pkg/pagination" 23 + "github.com/oscar345/keeptrack/pkg/validation" 20 24 "github.com/oscar345/keeptrack/private" 21 25 ) 22 26 ··· 26 30 inertia *inertia.Inertia 27 31 authenticator authentication.Authenticator 28 32 config *config.Config 33 + store *sessions.CookieStore 29 34 } 30 35 31 36 func New( ··· 33 38 userService services.UserService, 34 39 inertia *inertia.Inertia, 35 40 authenticator authentication.Authenticator, 41 + store *sessions.CookieStore, 36 42 config *config.Config, 37 43 ) *Server { 38 44 return &Server{ ··· 40 46 userService: userService, 41 47 inertia: inertia, 42 48 authenticator: authenticator, 49 + store: store, 43 50 config: config, 44 51 } 45 52 } ··· 58 65 chimiddleware.Logger, 59 66 chimiddleware.RequestID, 60 67 chimiddleware.CleanPath, 61 - inertia.Middleware, 62 - csrf.Protect([]byte(s.config.Server.SecretKey)), 68 + s.inertia.Middleware, 69 + csrf.Protect( 70 + []byte(s.config.Server.SecretKey), 71 + csrf.Secure(s.config.Environment == config.Production), 72 + csrf.TrustedOrigins([]string{"127.0.0.1:3000", "localhost:3000"}), 73 + ), 74 + middleware.InertiaCSRFToken, 63 75 ) 64 76 65 77 r.Handle("/assets*", assetsFileSystemHandler(s.config)) ··· 81 93 }) 82 94 83 95 r.With(authentication.Middleware(s.authenticator)).Get("/test", func(w http.ResponseWriter, r *http.Request) { 96 + w.Write([]byte("Hello World")) 97 + }) 98 + 99 + r.Get("/authentication/login", func(w http.ResponseWriter, r *http.Request) { 100 + s.inertia.Render(w, r, "authentication/Login", inertia.Props{}) 101 + }) 102 + 103 + r.Post("/authentication/login", func(w http.ResponseWriter, r *http.Request) { 104 + var form requests.LoginForm 105 + if err := json.NewDecoder(r.Body).Decode(&form); err != nil { 106 + http.Error(w, err.Error(), http.StatusBadRequest) 107 + return 108 + } 109 + 110 + if err := form.Validate(); err != nil { 111 + s.inertia.SetErrors(w, r, err.(validation.Errors)) 112 + http.Redirect(w, r, "/authentication/login", 302) 113 + return 114 + } 115 + 84 116 w.Write([]byte("Hello World")) 85 117 }) 86 118
+17 -2
pkg/inertia/context.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 "net/http" 6 7 ) 7 8 ··· 16 17 partialExceptProps []string 17 18 } 18 19 19 - func Middleware(next http.Handler) http.Handler { 20 + func ContextFromRequest(r *http.Request) (Context, bool) { 21 + ctx, ok := r.Context().Value(ContextKey).(Context) 22 + fmt.Println(ctx) 23 + return ctx, ok 24 + } 25 + 26 + const SessionKey = "inertia-session-key" 27 + 28 + func (in *Inertia) Middleware(next http.Handler) http.Handler { 20 29 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 + data, err := in.Store.Get(r, SessionKey) 31 + 32 + if err != nil { 33 + fmt.Println(err) 34 + } 35 + 21 36 ctx := Context{ 22 - props: make(Props), 37 + props: Props{"errors": Default(data.Errors)}, 23 38 isInertiaRequest: isInertiaRequest(r), 24 39 isPartialRequest: isPartialRequest(r), 25 40 version: getVersion(r),
+13 -1
pkg/inertia/inertia.go
··· 2 2 3 3 import ( 4 4 "html/template" 5 + "net/http" 5 6 ) 6 7 8 + type SessionData struct { 9 + Errors map[string][]string 10 + } 11 + 12 + type SessionStore interface { 13 + Set(w http.ResponseWriter, r *http.Request, key string, value SessionData) error 14 + Get(r *http.Request, key string) (SessionData, error) 15 + } 16 + 7 17 type Inertia struct { 8 18 Version string 9 19 Template *template.Template 20 + Store SessionStore 10 21 } 11 22 12 - func New(tmpl *template.Template, options ...NewInertiaOption) *Inertia { 23 + func New(tmpl *template.Template, store SessionStore, options ...NewInertiaOption) *Inertia { 13 24 i := &Inertia{ 14 25 Template: tmpl, 15 26 Version: "1", 27 + Store: store, 16 28 } 17 29 18 30 for _, option := range options {
+20 -5
pkg/inertia/prop.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 6 + "log" 5 7 "net/http" 6 8 "slices" 7 9 ) ··· 121 123 // use the SetProp function to set props. The value will be stored in the request context and will 122 124 // be retrieved when rendering the page. 123 125 func SetProp(r *http.Request, key string, value Prop) { 124 - props, ok := r.Context().Value(ContextKey).(Props) 126 + ctx, ok := r.Context().Value(ContextKey).(Context) 127 + 128 + fmt.Println(ctx.props) 125 129 126 130 if !ok { 127 - props = make(Props) 131 + log.Panicln("context not found") 128 132 } 129 133 130 - props[key] = value 134 + ctx.props[key] = value 131 135 132 - ctx := context.WithValue(r.Context(), ContextKey, props) 136 + r.WithContext(context.WithValue(r.Context(), ContextKey, ctx)) 137 + } 133 138 134 - *r = *r.WithContext(ctx) 139 + func (in *Inertia) SetErrors(w http.ResponseWriter, r *http.Request, errors map[string][]string) { 140 + data, err := in.Store.Get(r, SessionKey) 141 + if err != nil { 142 + log.Println(err) 143 + } 144 + data.Errors = errors 145 + fmt.Println(data) 146 + if err := in.Store.Set(w, r, SessionKey, data); err != nil { 147 + log.Println(err) 148 + } 149 + SetProp(r, "errors", Default(errors)) 135 150 }
+1 -2
pkg/inertia/response.go
··· 15 15 errors = make(map[string][]string) 16 16 ) 17 17 18 + // here 18 19 ctx, ok := r.Context().Value(ContextKey).(Context) 19 20 if !ok { 20 21 log.Fatalln("Failed to get inertia context, did you forget to use inertia middleware?") ··· 42 43 } 43 44 properties[key] = value 44 45 } 45 - 46 - properties["errors"] = errors 47 46 48 47 page := Page{ 49 48 Component: view,
+16 -14
pkg/validation/validation.go
··· 1 1 package validation 2 2 3 3 import ( 4 - "encoding/json" 5 4 "fmt" 6 5 ) 7 6 8 7 type Options struct { 9 - FlatMap bool 8 + NestedMap bool 10 9 } 11 10 12 11 type Option func(*Options) 13 12 13 + type Errors map[string][]string 14 + 14 15 type Validator struct { 15 - errors map[string][]string 16 + errors Errors 16 17 options Options 17 18 } 18 19 19 - func WithFlatMap() Option { 20 + func WithNestedMap() Option { 20 21 return func(o *Options) { 21 - o.FlatMap = true 22 + o.NestedMap = true 22 23 } 23 24 } 24 25 25 26 func New(opts ...Option) *Validator { 26 27 options := Options{ 27 - FlatMap: false, 28 + NestedMap: false, 28 29 } 29 30 30 31 for _, opt := range opts { ··· 37 38 } 38 39 } 39 40 41 + // Add validations for a key. For every validation that fails, the message is added to the errors 42 + // map of the `Validator`. You can choose to use validation messages from this package. Nested 43 + // validations like a city of an address can be added by using a dot notation, like so 44 + // `address.city`. 40 45 func (v *Validator) Validate(key string, validations map[string]bool) { 41 46 for message, isValid := range validations { 42 47 if !isValid { 43 - v.errors[message] = append(v.errors[message], key) 48 + v.errors[key] = append(v.errors[key], message) 44 49 } 45 50 } 46 51 } 47 52 48 - func (e Validator) Error() string { 49 - return fmt.Sprintf("%v", e.errors) 53 + func (e Errors) Error() string { 54 + return fmt.Sprintf("%v", e) 50 55 } 51 56 57 + // Run returns an error if there are any validation errors for one of the keys. 52 58 func (e Validator) Run() error { 53 59 if len(e.errors) > 0 { 54 - return e 60 + return e.errors 55 61 } 56 62 57 63 return nil 58 64 } 59 - 60 - func (v Validator) MarshalJSON() ([]byte, error) { 61 - return json.Marshal(v.errors) 62 - }
+1 -2
taskfile.yml
··· 120 120 serve: 121 121 watch: true 122 122 sources: 123 - - "resources/**/*.html" 124 - - "**/*.go" 123 + - "{internal,pkg}/**/*.go" 125 124 deps: 126 125 - task: watch:css 127 126 - task: watch:js
+9 -1
web/lib/app.ts
··· 1 1 import { default as Base } from "$components/layouts/Layout.svelte"; 2 2 import { default as Web } from "$components/layouts/web/Layout.svelte"; 3 - import { createInertiaApp } from "@inertiajs/svelte"; 3 + import { createInertiaApp, page } from "@inertiajs/svelte"; 4 4 import { mount } from "svelte"; 5 + import axios from "axios"; 6 + 7 + page.subscribe((page) => { 8 + if (page) { 9 + axios.defaults.headers.common["X-CSRF-Token"] = page.props.csrf_token; 10 + axios.defaults.withCredentials = true; 11 + } 12 + }); 5 13 6 14 createInertiaApp({ 7 15 id: "app",
+36
web/lib/types.ts
··· 1 + import type { Snippet } from "svelte"; 2 + import type { 3 + HTMLAnchorAttributes, 4 + HTMLButtonAttributes, 5 + HTMLInputAttributes, 6 + HTMLSelectAttributes, 7 + } from "svelte/elements"; 8 + 1 9 export type NavigationItemProps = { 2 10 label: string; 3 11 href: string | MethodURL; ··· 10 18 method: Method; 11 19 url: string; 12 20 }; 21 + 22 + export type SelectProps = HTMLSelectAttributes & { 23 + values: { 24 + label: string; 25 + value: string; 26 + current?: boolean; 27 + disabled?: boolean; 28 + }[]; 29 + }; 30 + 31 + export type InputProps = HTMLInputAttributes; 32 + 33 + type ButtonPropsExtension = { 34 + children?: Snippet; 35 + scheme?: "default" | "primary"; 36 + variant?: "default" | "text"; 37 + shape?: "square" | "rounded" | "circle"; 38 + icon?: string; 39 + icon_position?: "left" | "right"; 40 + }; 41 + 42 + export type ButtonProps = ButtonPropsExtension & HTMLButtonAttributes; 43 + export type LinkProps = Omit<HTMLAnchorAttributes, "href"> & 44 + ButtonPropsExtension & { 45 + external?: boolean; 46 + disabled?: boolean; 47 + href: string | { url: string; method: Method }; 48 + };
+32 -7
web/package-lock.json
··· 4 4 "requires": true, 5 5 "packages": { 6 6 "": { 7 + "name": "web", 7 8 "dependencies": { 8 9 "@inertiajs/svelte": "2.3.8", 9 10 "svelte": "^5.46.3", ··· 11 12 }, 12 13 "devDependencies": { 13 14 "@tailwindcss/cli": "^4.1.18", 15 + "@tailwindcss/forms": "^0.5.11", 14 16 "@types/node": "^25.0.3", 15 17 "esbuild": "0.27.2", 16 18 "esbuild-svelte": "^0.9.4", ··· 870 872 "tailwindcss": "dist/index.mjs" 871 873 } 872 874 }, 875 + "node_modules/@tailwindcss/forms": { 876 + "version": "0.5.11", 877 + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", 878 + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", 879 + "dev": true, 880 + "license": "MIT", 881 + "dependencies": { 882 + "mini-svg-data-uri": "^1.2.3" 883 + }, 884 + "peerDependencies": { 885 + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" 886 + } 887 + }, 873 888 "node_modules/@tailwindcss/node": { 874 889 "version": "4.1.18", 875 890 "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", ··· 1275 1290 } 1276 1291 }, 1277 1292 "node_modules/devalue": { 1278 - "version": "5.6.1", 1279 - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", 1280 - "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", 1293 + "version": "5.6.2", 1294 + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", 1295 + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", 1281 1296 "license": "MIT" 1282 1297 }, 1283 1298 "node_modules/dunder-proto": { ··· 1931 1946 "node": ">= 0.6" 1932 1947 } 1933 1948 }, 1949 + "node_modules/mini-svg-data-uri": { 1950 + "version": "1.4.4", 1951 + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", 1952 + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", 1953 + "dev": true, 1954 + "license": "MIT", 1955 + "bin": { 1956 + "mini-svg-data-uri": "cli.js" 1957 + } 1958 + }, 1934 1959 "node_modules/mri": { 1935 1960 "version": "1.2.0", 1936 1961 "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", ··· 2084 2109 } 2085 2110 }, 2086 2111 "node_modules/svelte": { 2087 - "version": "5.46.3", 2088 - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.3.tgz", 2089 - "integrity": "sha512-Y5juST3x+/ySty5tYJCVWa6Corkxpt25bUZQHqOceg9xfMUtDsFx6rCsG6cYf1cA6vzDi66HIvaki0byZZX95A==", 2112 + "version": "5.46.4", 2113 + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.4.tgz", 2114 + "integrity": "sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==", 2090 2115 "license": "MIT", 2091 2116 "dependencies": { 2092 2117 "@jridgewell/remapping": "^2.3.4", ··· 2097 2122 "aria-query": "^5.3.1", 2098 2123 "axobject-query": "^4.1.0", 2099 2124 "clsx": "^2.1.1", 2100 - "devalue": "^5.5.0", 2125 + "devalue": "^5.6.2", 2101 2126 "esm-env": "^1.2.1", 2102 2127 "esrap": "^2.2.1", 2103 2128 "is-reference": "^3.0.3",
+3 -2
web/package.json
··· 1 1 { 2 2 "devDependencies": { 3 + "@tailwindcss/cli": "^4.1.18", 4 + "@tailwindcss/forms": "^0.5.11", 3 5 "@types/node": "^25.0.3", 4 6 "esbuild": "0.27.2", 5 7 "esbuild-svelte": "^0.9.4", 6 8 "svelte-preprocess": "^6.0.3", 7 - "typescript": "^5.6.3", 8 - "@tailwindcss/cli": "^4.1.18" 9 + "typescript": "^5.6.3" 9 10 }, 10 11 "dependencies": { 11 12 "@inertiajs/svelte": "2.3.8",