├── .github ├── CODEOWNERS └── workflows │ └── cicd.yml ├── dev.dockerfile ├── docker-compose.yaml ├── main_test.go ├── .gitignore ├── handlers ├── handlers.go ├── root.go ├── settings.go ├── search.go ├── auth.go └── mappings.go ├── dockerfile ├── .air.toml ├── data ├── libsql_test.go ├── libsql.go ├── data_test.go └── data.go ├── views ├── modal.templ ├── login.templ ├── home.templ ├── search.templ ├── settings.templ ├── page.templ ├── login_templ.go ├── mappings.templ ├── modal_templ.go ├── home_templ.go ├── page_templ.go ├── settings_templ.go ├── search_templ.go └── mappings_templ.go ├── auth ├── session.go └── auth.go ├── LICENSE ├── go.mod ├── config ├── config_test.go └── config.go ├── .renovaterc.json5 ├── main.go ├── public ├── js │ └── input.js └── css │ └── styles.css ├── README.md ├── go.sum └── CHANGELOG.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 2 | * @scottmckendry 3 | -------------------------------------------------------------------------------- /dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 2 | 3 | WORKDIR /mnemstart 4 | COPY . /mnemstart/ 5 | RUN mkdir -p /mnemstart/data 6 | RUN go mod download 7 | RUN go mod verify 8 | RUN go install github.com/a-h/templ/cmd/templ@latest 9 | RUN go install github.com/air-verse/air@latest 10 | CMD air -c .air.toml 11 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | container_name: "mnemstart" 4 | build: 5 | context: ./ 6 | dockerfile: dev.dockerfile 7 | volumes: 8 | - ./:/mnemstart 9 | ports: 10 | - 3000:3000 11 | - 8080:8080 12 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/scottmckendry/mnemstart/data" 7 | ) 8 | 9 | func TestDbInit(t *testing.T) { 10 | db, _ := data.NewLibSqlDatabase("file:test.db") 11 | initStorage(db) 12 | 13 | err := db.Ping() 14 | if err != nil { 15 | t.Errorf("Error pinging database: %v", err) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Temporary files 15 | tmp/* 16 | 17 | # Go workspace file 18 | go.work 19 | go.work.sum 20 | 21 | # app 22 | .env 23 | *.db 24 | sessions/* 25 | -------------------------------------------------------------------------------- /handlers/handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/scottmckendry/mnemstart/auth" 5 | "github.com/scottmckendry/mnemstart/data" 6 | ) 7 | 8 | type Handler struct { 9 | store *data.Storage 10 | auth *auth.AuthService 11 | } 12 | 13 | func New(store *data.Storage, auth *auth.AuthService) *Handler { 14 | return &Handler{ 15 | store: store, 16 | auth: auth, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /handlers/root.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | 7 | "github.com/scottmckendry/mnemstart/views" 8 | ) 9 | 10 | func (h *Handler) HandleRoot(w http.ResponseWriter, r *http.Request) { 11 | user, err := h.auth.GetSessionUser(r) 12 | if err != nil { 13 | slog.Error("failed to get session user", "error", err) 14 | return 15 | } 16 | 17 | userSettings := h.store.GetUserSettings(user.Email) 18 | mappings := h.store.GetMappings(user.Email) 19 | 20 | views.Home(user, userSettings, mappings).Render(r.Context(), w) 21 | } 22 | 23 | func (h *Handler) HandleHelp(w http.ResponseWriter, r *http.Request) { 24 | views.Help().Render(r.Context(), w) 25 | } 26 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | # Builder stage 2 | FROM golang:1.24 AS builder 3 | 4 | WORKDIR /build 5 | COPY . . 6 | 7 | RUN go install github.com/a-h/templ/cmd/templ@latest 8 | RUN templ generate 9 | RUN CGO_ENABLED=0 GOOS=linux go build -o mnemstart 10 | 11 | # Copy built binary and static files to a new image 12 | FROM alpine:latest 13 | 14 | RUN adduser -D -u 1000 mnemstart 15 | 16 | WORKDIR /app 17 | COPY --from=builder /build/mnemstart . 18 | COPY --from=builder /build/public ./public 19 | 20 | RUN chown -R root:root /app && \ 21 | chmod -R 755 /app && \ 22 | mkdir -p /app/data && \ 23 | chown -R mnemstart:mnemstart /app 24 | 25 | USER mnemstart 26 | EXPOSE 3000 27 | ENTRYPOINT ["./mnemstart"] 28 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "templ generate && go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go", "_templ.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html", "css", "toml", "templ"] 18 | kill_delay = "0s" 19 | log = "build-errors.log" 20 | send_interrupt = false 21 | stop_on_error = false 22 | poll = true 23 | 24 | [color] 25 | app = "" 26 | build = "yellow" 27 | main = "magenta" 28 | runner = "green" 29 | watcher = "cyan" 30 | 31 | [log] 32 | time = false 33 | 34 | [misc] 35 | clean_on_exit = false 36 | 37 | [screen] 38 | clear_on_rebuild = true 39 | 40 | [proxy] 41 | enabled = true 42 | proxy_port = 8080 43 | app_port = 3000 44 | -------------------------------------------------------------------------------- /data/libsql_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDbInit(t *testing.T) { 8 | db, _ := NewLibSqlDatabase("file:test.db") 9 | err := db.Ping() 10 | if err != nil { 11 | t.Errorf("Error pinging database: %v", err) 12 | } 13 | } 14 | 15 | func TestGenerateSchema(t *testing.T) { 16 | db, _ := NewLibSqlDatabase("file:test.db") 17 | err := generateSchema(db) 18 | if err != nil { 19 | t.Errorf("Error generating schema: %v", err) 20 | } 21 | 22 | tables := []string{"users", "mappings", "user_settings"} 23 | for _, table := range tables { 24 | t.Run(table, func(t *testing.T) { 25 | rows, err := db.Query( 26 | "SELECT name FROM sqlite_master WHERE type='table' AND name=?", 27 | table, 28 | ) 29 | if err != nil { 30 | t.Errorf("Error querying for table %s: %v", table, err) 31 | } 32 | defer rows.Close() 33 | 34 | if !rows.Next() { 35 | t.Errorf("Table %s not found", table) 36 | } 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /views/modal.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | templ Modal(title string, reloadOnClose bool) { 4 | 23 | 30 | } 31 | -------------------------------------------------------------------------------- /views/login.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/markbates/goth" 4 | 5 | templ Login() { 6 | @Page(false, goth.User{}) { 7 |
8 |
9 |

Continue with

10 | 32 |
33 |
34 | } 35 | } 36 | -------------------------------------------------------------------------------- /auth/session.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/gorilla/sessions" 8 | ) 9 | 10 | const ( 11 | SessionName = "session" 12 | ) 13 | 14 | type SessionOptions struct { 15 | StorePath string 16 | CookiesKey string 17 | MaxAge int 18 | HttpOnly bool // Should be true if the site is served over HTTP (development environment) 19 | Secure bool // Should be true if the site is served over HTTPS (production environment) 20 | } 21 | 22 | func NewFileStore(opts SessionOptions) (*sessions.FilesystemStore, error) { 23 | if err := os.MkdirAll(opts.StorePath, 0700); err != nil { 24 | return nil, fmt.Errorf("failed to create session storage directory: %w", err) 25 | } 26 | 27 | store := sessions.NewFilesystemStore(opts.StorePath, []byte(opts.CookiesKey)) 28 | 29 | store.MaxAge(opts.MaxAge) 30 | store.MaxLength(8192) 31 | store.Options.Path = "/" 32 | store.Options.HttpOnly = opts.HttpOnly 33 | store.Options.Secure = opts.Secure 34 | 35 | return store, nil 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Scott McKendry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /views/home.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "github.com/markbates/goth" 5 | "github.com/scottmckendry/mnemstart/data" 6 | ) 7 | 8 | templ Home(user goth.User, settings *data.UserSettings, mappings []data.Mapping) { 9 | @templ.JSONScript("userSettings", settings) 10 | @templ.JSONScript("mappings", mappings) 11 | @Page(true, user) { 12 |
13 | @Clock() 14 | @Search(settings) 15 |
16 |

17 | 18 | } 19 | } 20 | 21 | templ Clock() { 22 |
23 |
24 | 38 | } 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/scottmckendry/mnemstart 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/a-h/templ v0.3.924 7 | github.com/go-chi/chi/v5 v5.2.2 8 | github.com/gorilla/sessions v1.1.1 9 | github.com/joho/godotenv v1.5.1 10 | github.com/markbates/goth v1.82.0 11 | github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d 12 | modernc.org/sqlite v1.38.0 13 | ) 14 | 15 | require ( 16 | cloud.google.com/go/compute/metadata v0.7.0 // indirect 17 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 18 | github.com/coder/websocket v1.8.13 // indirect 19 | github.com/dustin/go-humanize v1.0.1 // indirect 20 | github.com/google/uuid v1.6.0 // indirect 21 | github.com/gorilla/context v1.1.1 // indirect 22 | github.com/gorilla/mux v1.8.1 // indirect 23 | github.com/gorilla/securecookie v1.1.2 // indirect 24 | github.com/mattn/go-isatty v0.0.20 // indirect 25 | github.com/ncruces/go-strftime v0.1.9 // indirect 26 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 27 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 28 | golang.org/x/oauth2 v0.30.0 // indirect 29 | golang.org/x/sys v0.33.0 // indirect 30 | modernc.org/libc v1.66.0 // indirect 31 | modernc.org/mathutil v1.7.1 // indirect 32 | modernc.org/memory v1.11.0 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /handlers/settings.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | 7 | "github.com/scottmckendry/mnemstart/views" 8 | ) 9 | 10 | func (h *Handler) HandleSettings(w http.ResponseWriter, r *http.Request) { 11 | user, err := h.auth.GetSessionUser(r) 12 | if err != nil { 13 | slog.Error("failed to get session user", "error", err) 14 | return 15 | } 16 | 17 | userSettings := h.store.GetUserSettings(user.Email) 18 | views.Settings(user, userSettings).Render(r.Context(), w) 19 | } 20 | 21 | func (h *Handler) HandleSettingsUpdate(w http.ResponseWriter, r *http.Request) { 22 | user, err := h.auth.GetSessionUser(r) 23 | if err != nil { 24 | slog.Error("failed to get session user", "error", err) 25 | return 26 | } 27 | 28 | err = r.ParseForm() 29 | if err != nil { 30 | slog.Error("failed to parse form", "error", err) 31 | return 32 | } 33 | 34 | userSettings := h.store.GetUserSettings(user.Email) 35 | userSettings.LeaderKey = r.FormValue("leaderKey") 36 | userSettings.SearchEngine = r.FormValue("searchEngine") 37 | userSettings.ShowSuggestions = r.FormValue("suggestions") == "on" 38 | 39 | h.store.UpdateUserSettings(user.Email, userSettings) 40 | 41 | mappings := h.store.GetMappings(user.Email) 42 | 43 | w.Header().Set("HX-Refresh", "true") 44 | views.Home(user, userSettings, mappings).Render(r.Context(), w) 45 | } 46 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestGetEnv(t *testing.T) { 9 | os.Setenv("TEST_ENV", "test_value") 10 | 11 | result := getEnv("TEST_ENV", "fallback_value") 12 | if result != "test_value" { 13 | t.Errorf("Expected 'test_value', got '%s'", result) 14 | } 15 | 16 | result = getEnv("NON_EXISTENT_ENV", "fallback_value") 17 | if result != "fallback_value" { 18 | t.Errorf("Expected 'fallback_value', got '%s'", result) 19 | } 20 | 21 | t.Cleanup(func() { 22 | os.Unsetenv("TEST_ENV") 23 | }) 24 | } 25 | 26 | func TestGetEnvAsInt(t *testing.T) { 27 | os.Setenv("TEST_ENV", "123") 28 | 29 | result := getEnvAsInt("TEST_ENV", 456) 30 | if result != 123 { 31 | t.Errorf("Expected 123, got %d", result) 32 | } 33 | 34 | result = getEnvAsInt("NON_EXISTENT_ENV", 456) 35 | if result != 456 { 36 | t.Errorf("Expected 456, got %d", result) 37 | } 38 | 39 | t.Cleanup(func() { 40 | os.Unsetenv("TEST_ENV") 41 | }) 42 | } 43 | 44 | func TestGetEnvAsBool(t *testing.T) { 45 | os.Setenv("TEST_ENV", "true") 46 | 47 | result := getEnvAsBool("TEST_ENV", false) 48 | if result != true { 49 | t.Errorf("Expected true, got %v", result) 50 | } 51 | 52 | result = getEnvAsBool("NON_EXISTENT_ENV", false) 53 | if result != false { 54 | t.Errorf("Expected false, got %v", result) 55 | } 56 | 57 | t.Cleanup(func() { 58 | os.Unsetenv("TEST_ENV") 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /data/libsql.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | 9 | _ "github.com/tursodatabase/libsql-client-go/libsql" 10 | _ "modernc.org/sqlite" 11 | ) 12 | 13 | func NewLibSqlDatabase(url string) (*sql.DB, error) { 14 | db, err := sql.Open("libsql", url) 15 | if err != nil { 16 | slog.Error("failed to open database", "error", err) 17 | os.Exit(1) 18 | } 19 | 20 | err = generateSchema(db) 21 | if err != nil { 22 | return nil, fmt.Errorf("Error generating schema: %v", err) 23 | } 24 | 25 | return db, nil 26 | } 27 | 28 | func generateSchema(db *sql.DB) error { 29 | _, err := db.Exec(` 30 | CREATE TABLE IF NOT EXISTS users ( 31 | id INTEGER PRIMARY KEY, 32 | name TEXT, 33 | email TEXT, 34 | discord_id TEXT, 35 | github_id TEXT, 36 | google_id TEXT, 37 | gitlab_id TEXT 38 | ); 39 | `) 40 | if err != nil { 41 | return fmt.Errorf("Error creating users table: %v", err) 42 | } 43 | 44 | _, err = db.Exec(` 45 | CREATE TABLE IF NOT EXISTS mappings ( 46 | id INTEGER PRIMARY KEY, 47 | user_id INTEGER, 48 | keymap TEXT, 49 | maps_to TEXT 50 | ); 51 | `) 52 | if err != nil { 53 | return fmt.Errorf("Error creating mappings table: %v", err) 54 | } 55 | 56 | _, err = db.Exec(` 57 | CREATE TABLE IF NOT EXISTS user_settings ( 58 | id INTEGER PRIMARY KEY, 59 | user_id INTEGER, 60 | setting_key TEXT, 61 | setting_value TEXT 62 | ); 63 | `) 64 | if err != nil { 65 | return fmt.Errorf("Error creating user_settings table: %v", err) 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /views/search.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "github.com/scottmckendry/mnemstart/data" 5 | ) 6 | 7 | type SearchEngine struct { 8 | URL string 9 | QueryParam string 10 | } 11 | 12 | var searchEngines = map[string]SearchEngine{ 13 | "Google": { 14 | URL: "https://www.google.com/search", 15 | QueryParam: "q", 16 | }, 17 | "DuckDuckGo": { 18 | URL: "https://duckduckgo.com/", 19 | QueryParam: "q", 20 | }, 21 | "Bing": { 22 | URL: "https://www.bing.com/search", 23 | QueryParam: "q", 24 | }, 25 | "Brave Search": { 26 | URL: "https://search.brave.com/search", 27 | QueryParam: "q", 28 | }, 29 | } 30 | 31 | templ Search(settings *data.UserSettings) { 32 |
33 | if settings.ShowSuggestions { 34 | 44 | } else { 45 | 52 | } 53 | // hidden form value that indicates the search engine 54 | 55 |
56 |
57 | } 58 | 59 | templ Suggestions(suggestions []string, engine string) { 60 | for _, suggestion := range suggestions { 61 | 63 | { suggestion } 64 | 65 | 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /views/settings.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "github.com/markbates/goth" 5 | "github.com/scottmckendry/mnemstart/data" 6 | ) 7 | 8 | templ Settings(user goth.User, settings *data.UserSettings) { 9 | @Modal("Settings", false) { 10 |
11 |
12 | 13 | 14 |
15 |
16 | 17 | 23 |
24 |
25 | 34 | 38 |
39 |
40 | 41 |
42 |
43 | } 44 | } 45 | -------------------------------------------------------------------------------- /handlers/search.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/scottmckendry/mnemstart/views" 12 | ) 13 | 14 | func (h *Handler) HandleSearchSuggest(w http.ResponseWriter, r *http.Request) { 15 | engine := r.FormValue("search_engine") 16 | query := r.FormValue("q") 17 | query = url.QueryEscape(query) 18 | 19 | slog.Info("getting search suggestions", 20 | "engine", engine, 21 | "query", query) 22 | 23 | body, err := getGoogleSuggestions(query) 24 | if err != nil { 25 | slog.Error("failed to get suggestions from Google API", 26 | "error", err, 27 | "query", query) 28 | http.Error( 29 | w, 30 | fmt.Sprintf("Error getting suggestions: %v", err), 31 | http.StatusInternalServerError, 32 | ) 33 | return 34 | } 35 | 36 | var suggestions []any 37 | err = json.Unmarshal(body, &suggestions) 38 | if err != nil { 39 | slog.Error("failed to unmarshal suggestions", 40 | "error", err, 41 | "body", string(body)) 42 | http.Error( 43 | w, 44 | fmt.Sprintf("Error unmarshalling suggestions: %v", err), 45 | http.StatusInternalServerError, 46 | ) 47 | return 48 | } 49 | 50 | parsedSuggestions := parseSuggestions(suggestions[1].([]any)) 51 | views.Suggestions(parsedSuggestions, engine).Render(r.Context(), w) 52 | } 53 | 54 | func getGoogleSuggestions(query string) ([]byte, error) { 55 | url := "https://suggestqueries.google.com/complete/search?client=firefox&q=" + query 56 | slog.Debug("calling Google suggestions API", "url", url) 57 | 58 | resp, err := http.Get(url) 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to call Google API: %w", err) 61 | } 62 | defer resp.Body.Close() 63 | 64 | body, err := io.ReadAll(resp.Body) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to read response body: %w", err) 67 | } 68 | 69 | return body, nil 70 | } 71 | 72 | func parseSuggestions(suggestions []any) []string { 73 | var parsedSuggestions []string 74 | for _, suggestion := range suggestions { 75 | parsedSuggestions = append(parsedSuggestions, suggestion.(string)) 76 | } 77 | 78 | return parsedSuggestions 79 | } 80 | -------------------------------------------------------------------------------- /handlers/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/markbates/goth/gothic" 10 | 11 | "github.com/scottmckendry/mnemstart/views" 12 | ) 13 | 14 | func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { 15 | views.Login().Render(r.Context(), w) 16 | } 17 | 18 | func (h *Handler) HandleProviderLogin(w http.ResponseWriter, r *http.Request) { 19 | provider := chi.URLParam(r, "provider") 20 | r = r.WithContext(context.WithValue(r.Context(), "provider", provider)) 21 | 22 | // try to get the user without re-authenticating 23 | if u, err := gothic.CompleteUserAuth(w, r); err == nil { 24 | slog.Info("user already authenticated", "user", u) 25 | views.Login().Render(r.Context(), w) 26 | } else { 27 | gothic.BeginAuthHandler(w, r) 28 | } 29 | } 30 | 31 | func (h *Handler) HandleAuthCallbackFunction(w http.ResponseWriter, r *http.Request) { 32 | provider := chi.URLParam(r, "provider") 33 | r = r.WithContext(context.WithValue(r.Context(), "provider", provider)) 34 | 35 | user, err := gothic.CompleteUserAuth(w, r) 36 | if err != nil { 37 | slog.Error("failed to complete user authentication", "error", err) 38 | http.Error(w, err.Error(), http.StatusInternalServerError) 39 | return 40 | } 41 | 42 | err = h.auth.StoreUserSession(w, r, user) 43 | if err != nil { 44 | slog.Error("failed to store user session", "error", err) 45 | return 46 | } 47 | 48 | err = h.store.CreateOrUpdateUser(user) 49 | if err != nil { 50 | slog.Error("failed to create or update user", "error", err) 51 | return 52 | } 53 | 54 | w.Header().Set("Location", "/") 55 | w.WriteHeader(http.StatusTemporaryRedirect) 56 | } 57 | 58 | func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) { 59 | provider := chi.URLParam(r, "provider") 60 | r = r.WithContext(context.WithValue(r.Context(), "provider", provider)) 61 | 62 | slog.Info("logging out user") 63 | 64 | err := gothic.Logout(w, r) 65 | if err != nil { 66 | slog.Error("failed to logout user", "error", err) 67 | return 68 | } 69 | 70 | h.auth.RemoveUserSession(w, r) 71 | 72 | w.Header().Set("Location", "/") 73 | w.WriteHeader(http.StatusTemporaryRedirect) 74 | } 75 | -------------------------------------------------------------------------------- /.renovaterc.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: [ 4 | "config:recommended", 5 | "docker:enableMajor", 6 | "helpers:pinGitHubActionDigests", 7 | ":dependencyDashboard", 8 | ":disableRateLimiting", 9 | ":semanticCommits", 10 | ], 11 | dependencyDashboard: true, 12 | dependencyDashboardTitle: "Renovate Dashboard 🤖", 13 | suppressNotifications: [ 14 | "prEditedNotification", 15 | "prIgnoreNotification", 16 | ], 17 | timezone: "Pacific/Auckland", 18 | packageRules: [ 19 | { 20 | matchUpdateTypes: ["major"], 21 | semanticCommitType: "feat", 22 | commitMessagePrefix: "{{semanticCommitType}}({{semanticCommitScope}})!:", 23 | commitMessageExtra: "( {{currentVersion}} → {{newVersion}} )", 24 | }, 25 | { 26 | matchUpdateTypes: ["minor"], 27 | semanticCommitType: "feat", 28 | commitMessageExtra: "( {{currentVersion}} → {{newVersion}} )", 29 | }, 30 | { 31 | matchUpdateTypes: ["patch"], 32 | semanticCommitType: "fix", 33 | commitMessageExtra: "( {{currentVersion}} → {{newVersion}} )", 34 | }, 35 | { 36 | matchUpdateTypes: ["digest"], 37 | semanticCommitType: "chore", 38 | commitMessageExtra: "( {{currentDigestShort}} → {{newDigestShort}} )", 39 | }, 40 | { 41 | matchDatasources: ["docker"], 42 | semanticCommitScope: "container", 43 | commitMessageTopic: "image {{depName}}", 44 | }, 45 | { 46 | matchManagers: ["github-actions"], 47 | semanticCommitType: "ci", 48 | semanticCommitScope: "github-action", 49 | commitMessageTopic: "action {{depName}}", 50 | }, 51 | { 52 | matchDatasources: ["github-releases"], 53 | semanticCommitScope: "github-release", 54 | commitMessageTopic: "release {{depName}}", 55 | }, 56 | { 57 | matchUpdateTypes: ["major"], 58 | labels: ["type/major"], 59 | }, 60 | { 61 | matchUpdateTypes: ["minor"], 62 | labels: ["type/minor"], 63 | }, 64 | { 65 | matchUpdateTypes: ["patch"], 66 | labels: ["type/patch"], 67 | }, 68 | { 69 | matchManagers: ["github-actions"], 70 | addLabels: ["renovate/github-action"], 71 | }, 72 | { 73 | matchDatasources: ["github-releases"], 74 | addLabels: ["renovate/github-release"], 75 | } 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /views/page.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import "github.com/markbates/goth" 4 | import "fmt" 5 | 6 | templ Page(nav bool, user goth.User) { 7 | 8 | 9 | 10 | mnemstart 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | if nav { 22 | 42 | } 43 | { children... } 44 | 45 | 46 | } 47 | 48 | templ Help() { 49 | @Modal("Default Shortcuts", false) { 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
KeyAction
?Open this help dialog
EscClose this and any other open dialogs
iFocus the search input
alt + sOpen settings
alt + mOpen mappings
76 | } 77 | } 78 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/joho/godotenv" 9 | _ "github.com/joho/godotenv/autoload" 10 | ) 11 | 12 | const ( 13 | thirtyDaysInSeconds = 86400 * 30 14 | ) 15 | 16 | type Config struct { 17 | PublicHost string 18 | Port string 19 | SendPortInCallback bool 20 | DatabaseURL string 21 | CookiesAuthSecret string 22 | CookiesAuthAgeInSeconds int 23 | CookiesAuthIsSecure bool 24 | CookiesAuthIsHttpOnly bool 25 | GithubClientID string 26 | GithubClientSecret string 27 | DiscordClientID string 28 | DiscordClientSecret string 29 | GoogleClientID string 30 | GoogleClientSecret string 31 | GitlabClientID string 32 | GitlabClientSecret string 33 | } 34 | 35 | var Envs = initConfig() 36 | 37 | func initConfig() *Config { 38 | err := godotenv.Load() 39 | if err != nil { 40 | slog.Warn("No .env file found. Using default environment variables.") 41 | } 42 | 43 | return &Config{ 44 | PublicHost: getEnv("PUBLIC_HOST", "http://localhost"), 45 | Port: getEnv("PORT", "3000"), 46 | SendPortInCallback: getEnvAsBool("SEND_PORT_IN_CALLBACK", true), 47 | DatabaseURL: getEnv("DATABASE_URL", "file:data/mnemstart.db"), 48 | CookiesAuthSecret: getEnv("COOKIES_AUTH_SECRET", "youllneverguesswhatthisis"), 49 | CookiesAuthAgeInSeconds: getEnvAsInt("COOKIES_AUTH_AGE_IN_SECONDS", thirtyDaysInSeconds), 50 | CookiesAuthIsSecure: getEnvAsBool("COOKIES_AUTH_IS_SECURE", false), 51 | CookiesAuthIsHttpOnly: getEnvAsBool("COOKIES_AUTH_IS_HTTP_ONLY", true), 52 | GithubClientID: getEnv("GITHUB_CLIENT_ID", ""), 53 | GithubClientSecret: getEnv("GITHUB_CLIENT_SECRET", ""), 54 | DiscordClientID: getEnv("DISCORD_CLIENT_ID", ""), 55 | DiscordClientSecret: getEnv("DISCORD_CLIENT_SECRET", ""), 56 | GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""), 57 | GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""), 58 | GitlabClientID: getEnv("GITLAB_CLIENT_ID", ""), 59 | GitlabClientSecret: getEnv("GITLAB_CLIENT_SECRET", ""), 60 | } 61 | } 62 | 63 | func getEnv(key, fallback string) string { 64 | if value, ok := os.LookupEnv(key); ok { 65 | return value 66 | } 67 | return fallback 68 | } 69 | 70 | func getEnvAsInt(key string, fallback int) int { 71 | if value, ok := os.LookupEnv(key); ok { 72 | if intValue, err := strconv.Atoi(value); err == nil { 73 | return intValue 74 | } 75 | } 76 | return fallback 77 | } 78 | 79 | func getEnvAsBool(key string, fallback bool) bool { 80 | if value, ok := os.LookupEnv(key); ok { 81 | if boolValue, err := strconv.ParseBool(value); err == nil { 82 | return boolValue 83 | } 84 | } 85 | return fallback 86 | } 87 | -------------------------------------------------------------------------------- /views/login_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package views 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import "github.com/markbates/goth" 12 | 13 | func Login() templ.Component { 14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 16 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 17 | return templ_7745c5c3_CtxErr 18 | } 19 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 20 | if !templ_7745c5c3_IsBuffer { 21 | defer func() { 22 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 23 | if templ_7745c5c3_Err == nil { 24 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 25 | } 26 | }() 27 | } 28 | ctx = templ.InitializeContext(ctx) 29 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 30 | if templ_7745c5c3_Var1 == nil { 31 | templ_7745c5c3_Var1 = templ.NopComponent 32 | } 33 | ctx = templ.ClearChildren(ctx) 34 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 35 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 36 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 37 | if !templ_7745c5c3_IsBuffer { 38 | defer func() { 39 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 40 | if templ_7745c5c3_Err == nil { 41 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 42 | } 43 | }() 44 | } 45 | ctx = templ.InitializeContext(ctx) 46 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Continue with

") 47 | if templ_7745c5c3_Err != nil { 48 | return templ_7745c5c3_Err 49 | } 50 | return nil 51 | }) 52 | templ_7745c5c3_Err = Page(false, goth.User{}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 53 | if templ_7745c5c3_Err != nil { 54 | return templ_7745c5c3_Err 55 | } 56 | return nil 57 | }) 58 | } 59 | 60 | var _ = templruntime.GeneratedTemplate 61 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/go-chi/chi/v5/middleware" 12 | 13 | "github.com/scottmckendry/mnemstart/auth" 14 | "github.com/scottmckendry/mnemstart/config" 15 | "github.com/scottmckendry/mnemstart/data" 16 | "github.com/scottmckendry/mnemstart/handlers" 17 | ) 18 | 19 | func main() { 20 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 21 | slog.SetDefault(logger) 22 | 23 | db, err := data.NewLibSqlDatabase(config.Envs.DatabaseURL) 24 | if err != nil { 25 | slog.Error("failed to open database", "error", err) 26 | os.Exit(1) 27 | } 28 | 29 | store := data.NewStore(db) 30 | 31 | initStorage(db) 32 | 33 | sessionStore, err := auth.NewFileStore(auth.SessionOptions{ 34 | StorePath: "./data/sessions", 35 | CookiesKey: config.Envs.CookiesAuthSecret, 36 | MaxAge: config.Envs.CookiesAuthAgeInSeconds, 37 | HttpOnly: config.Envs.CookiesAuthIsHttpOnly, 38 | Secure: config.Envs.CookiesAuthIsSecure, 39 | }) 40 | if err != nil { 41 | slog.Error("failed to create session store", "error", err) 42 | os.Exit(1) 43 | } 44 | authService := auth.NewAuthService(sessionStore) 45 | 46 | r := chi.NewRouter() 47 | r.Use(middleware.Logger) 48 | r.Use(middleware.Recoverer) 49 | 50 | handler := handlers.New(store, authService) 51 | 52 | r.Group(func(r chi.Router) { 53 | // Require authentication for all routes in this group 54 | r.Use(auth.RequireAuth(authService)) 55 | 56 | // app routes 57 | r.Get("/", handler.HandleRoot) 58 | r.Get("/settings", handler.HandleSettings) 59 | r.Put("/update-settings", handler.HandleSettingsUpdate) 60 | r.Get("/mappings", handler.HandleMappings) 61 | r.Get("/mappings/{id}", handler.HandleMapping) 62 | r.Get("/mappings/new", handler.HandleMappingNew) 63 | r.Post("/mappings/add", handler.HandleMappingAdd) 64 | r.Get("/mappings/edit/{id}", handler.HandleMappingEdit) 65 | r.Put("/mappings/update/{id}", handler.HandleMappingUpdate) 66 | r.Delete("/mappings/delete/{id}", handler.HandleMappingDelete) 67 | r.Post("/search/suggest", handler.HandleSearchSuggest) 68 | r.Get("/help", handler.HandleHelp) 69 | }) 70 | 71 | // auth 72 | r.Get("/auth/{provider}", handler.HandleProviderLogin) 73 | r.Get("/auth/{provider}/callback", handler.HandleAuthCallbackFunction) 74 | r.Get("/auth/{provider}/logout", handler.HandleLogout) 75 | r.Get("/login", handler.HandleLogin) 76 | 77 | // static content 78 | r.Handle("/public/*", http.StripPrefix("/public/", http.FileServer(http.Dir("public")))) 79 | 80 | slog.Info("server starting", 81 | "host", config.Envs.PublicHost, 82 | "port", config.Envs.Port) 83 | 84 | if err := http.ListenAndServe(fmt.Sprintf(":%s", config.Envs.Port), r); err != nil { 85 | slog.Error("server failed to start", "error", err) 86 | os.Exit(1) 87 | } 88 | } 89 | 90 | func initStorage(db *sql.DB) { 91 | err := db.Ping() 92 | if err != nil { 93 | slog.Error("failed to ping database", "error", err) 94 | os.Exit(1) 95 | } 96 | 97 | slog.Info("database connected") 98 | } 99 | -------------------------------------------------------------------------------- /views/mappings.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "strconv" 5 | "github.com/markbates/goth" 6 | "github.com/scottmckendry/mnemstart/data" 7 | ) 8 | 9 | templ Mappings(user goth.User, mappings []data.Mapping) { 10 | @Modal("Edit key mappings", true) { 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | for _, mapping := range mappings { 21 | @MappingRow(&mapping) 22 | } 23 | 24 | 29 | 30 | 31 |
KeymapURL
25 | 28 |
32 | } 33 | } 34 | 35 | templ MappingRow(mapping *data.Mapping) { 36 | 37 | { mapping.Keymap } 38 | { mapping.MapsTo } 39 | 40 | 53 | 56 | 57 | 58 | } 59 | 60 | templ NewMapping() { 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 72 | 75 | 76 | 77 | } 78 | 79 | templ EditMapping(user goth.User, mapping *data.Mapping) { 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 91 | 94 | 95 | 96 | } 97 | -------------------------------------------------------------------------------- /handlers/mappings.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi/v5" 8 | 9 | "github.com/scottmckendry/mnemstart/views" 10 | ) 11 | 12 | func (h *Handler) HandleMappings(w http.ResponseWriter, r *http.Request) { 13 | user, err := h.auth.GetSessionUser(r) 14 | if err != nil { 15 | slog.Error("failed to get session user", "error", err) 16 | return 17 | } 18 | 19 | mappings := h.store.GetMappings(user.Email) 20 | views.Mappings(user, mappings).Render(r.Context(), w) 21 | } 22 | 23 | func (h *Handler) HandleMapping(w http.ResponseWriter, r *http.Request) { 24 | user, err := h.auth.GetSessionUser(r) 25 | if err != nil { 26 | slog.Error("failed to get session user", "error", err) 27 | return 28 | } 29 | 30 | mappingID := chi.URLParam(r, "id") 31 | mapping := h.store.GetMapping(mappingID, user.Email) 32 | views.MappingRow(mapping).Render(r.Context(), w) 33 | } 34 | 35 | func (h *Handler) HandleMappingNew(w http.ResponseWriter, r *http.Request) { 36 | views.NewMapping().Render(r.Context(), w) 37 | } 38 | 39 | func (h *Handler) HandleMappingAdd(w http.ResponseWriter, r *http.Request) { 40 | user, err := h.auth.GetSessionUser(r) 41 | if err != nil { 42 | slog.Error("failed to get session user", "error", err) 43 | return 44 | } 45 | 46 | err = r.ParseForm() 47 | if err != nil { 48 | slog.Error("failed to parse form", "error", err) 49 | return 50 | } 51 | 52 | keymap := r.FormValue("keymap") 53 | mapsTo := r.FormValue("mapsto") 54 | h.store.AddMapping(user.Email, keymap, mapsTo) 55 | mappings := h.store.GetMappings(user.Email) 56 | views.Mappings(user, mappings).Render(r.Context(), w) 57 | } 58 | 59 | func (h *Handler) HandleMappingDelete(w http.ResponseWriter, r *http.Request) { 60 | user, err := h.auth.GetSessionUser(r) 61 | if err != nil { 62 | slog.Error("failed to get session user", "error", err) 63 | return 64 | } 65 | 66 | mappingID := chi.URLParam(r, "id") 67 | h.store.DeleteMapping(mappingID, user.Email) 68 | mappings := h.store.GetMappings(user.Email) 69 | views.Mappings(user, mappings).Render(r.Context(), w) 70 | } 71 | 72 | func (h *Handler) HandleMappingEdit(w http.ResponseWriter, r *http.Request) { 73 | user, err := h.auth.GetSessionUser(r) 74 | if err != nil { 75 | slog.Error("failed to get session user", "error", err) 76 | return 77 | } 78 | 79 | mappingID := chi.URLParam(r, "id") 80 | mapping := h.store.GetMapping(mappingID, user.Email) 81 | views.EditMapping(user, mapping).Render(r.Context(), w) 82 | } 83 | 84 | func (h *Handler) HandleMappingUpdate(w http.ResponseWriter, r *http.Request) { 85 | user, err := h.auth.GetSessionUser(r) 86 | if err != nil { 87 | slog.Error("failed to get session user", "error", err) 88 | return 89 | } 90 | 91 | err = r.ParseForm() 92 | if err != nil { 93 | slog.Error("failed to parse form", "error", err) 94 | return 95 | } 96 | 97 | mappingID := chi.URLParam(r, "id") 98 | keymap := r.FormValue("keymap") 99 | mapsTo := r.FormValue("mapsto") 100 | h.store.UpdateMapping(mappingID, user.Email, keymap, mapsTo) 101 | mappings := h.store.GetMappings(user.Email) 102 | views.Mappings(user, mappings).Render(r.Context(), w) 103 | } 104 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/gob" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | 9 | "github.com/gorilla/sessions" 10 | "github.com/markbates/goth" 11 | "github.com/markbates/goth/gothic" 12 | "github.com/markbates/goth/providers/discord" 13 | "github.com/markbates/goth/providers/github" 14 | "github.com/markbates/goth/providers/gitlab" 15 | "github.com/markbates/goth/providers/google" 16 | 17 | "github.com/scottmckendry/mnemstart/config" 18 | ) 19 | 20 | type AuthService struct{} 21 | 22 | func init() { 23 | gob.Register(map[string]interface{}{}) 24 | } 25 | 26 | func NewAuthService(store sessions.Store) *AuthService { 27 | gothic.Store = store 28 | 29 | goth.UseProviders( 30 | github.New( 31 | config.Envs.GithubClientID, 32 | config.Envs.GithubClientSecret, 33 | buildCallbackURL("github"), "user:email", 34 | ), 35 | discord.New( 36 | config.Envs.DiscordClientID, 37 | config.Envs.DiscordClientSecret, 38 | buildCallbackURL("discord"), "identify", "email", 39 | ), 40 | google.New( 41 | config.Envs.GoogleClientID, 42 | config.Envs.GoogleClientSecret, 43 | buildCallbackURL("google"), "email", "profile", 44 | ), 45 | gitlab.New( 46 | config.Envs.GitlabClientID, 47 | config.Envs.GitlabClientSecret, 48 | buildCallbackURL("gitlab"), "read_user", "email", 49 | ), 50 | ) 51 | 52 | return &AuthService{} 53 | } 54 | 55 | func (a *AuthService) GetSessionUser(r *http.Request) (goth.User, error) { 56 | session, err := gothic.Store.Get(r, SessionName) 57 | if err != nil { 58 | return goth.User{}, err 59 | } 60 | 61 | user := session.Values["user"] 62 | if user == nil { 63 | return goth.User{}, fmt.Errorf("user is unauthenticated! %v", user) 64 | } 65 | 66 | return user.(goth.User), nil 67 | } 68 | 69 | func (a *AuthService) StoreUserSession( 70 | w http.ResponseWriter, 71 | r *http.Request, 72 | user goth.User, 73 | ) error { 74 | session, _ := gothic.Store.Get(r, SessionName) 75 | session.Values["user"] = user 76 | 77 | err := session.Save(r, w) 78 | if err != nil { 79 | http.Error(w, err.Error(), http.StatusInternalServerError) 80 | return err 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func (a *AuthService) RemoveUserSession(w http.ResponseWriter, r *http.Request) { 87 | session, err := gothic.Store.Get(r, SessionName) 88 | if err != nil { 89 | slog.Warn("User session not found") 90 | http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 91 | return 92 | } 93 | 94 | session.Values["user"] = goth.User{} 95 | session.Options.MaxAge = -1 96 | 97 | session.Save(r, w) 98 | } 99 | 100 | func RequireAuth(auth *AuthService) func(next http.Handler) http.Handler { 101 | return func(next http.Handler) http.Handler { 102 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 103 | session, err := auth.GetSessionUser(r) 104 | if err != nil { 105 | slog.Warn("user is not authenticated", "error", err) 106 | http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 107 | return 108 | } 109 | 110 | slog.Info("authenticated user", "email", session.Email) 111 | next.ServeHTTP(w, r) 112 | }) 113 | } 114 | } 115 | 116 | func buildCallbackURL(provider string) string { 117 | hostUrl := config.Envs.PublicHost 118 | if config.Envs.SendPortInCallback { 119 | hostUrl = fmt.Sprintf("%s:%s", hostUrl, config.Envs.Port) 120 | } 121 | return fmt.Sprintf( 122 | "%s/auth/%s/callback", 123 | hostUrl, 124 | provider, 125 | ) 126 | } 127 | -------------------------------------------------------------------------------- /views/modal_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package views 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func Modal(title string, reloadOnClose bool) templ.Component { 12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 | return templ_7745c5c3_CtxErr 16 | } 17 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 | if !templ_7745c5c3_IsBuffer { 19 | defer func() { 20 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 | if templ_7745c5c3_Err == nil { 22 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 | } 24 | }() 25 | } 26 | ctx = templ.InitializeContext(ctx) 27 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 | if templ_7745c5c3_Var1 == nil { 29 | templ_7745c5c3_Var1 = templ.NopComponent 30 | } 31 | ctx = templ.ClearChildren(ctx) 32 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") 48 | if templ_7745c5c3_Err != nil { 49 | return templ_7745c5c3_Err 50 | } 51 | var templ_7745c5c3_Var2 string 52 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) 53 | if templ_7745c5c3_Err != nil { 54 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/modal.templ`, Line: 15, Col: 15} 55 | } 56 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 57 | if templ_7745c5c3_Err != nil { 58 | return templ_7745c5c3_Err 59 | } 60 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") 61 | if templ_7745c5c3_Err != nil { 62 | return templ_7745c5c3_Err 63 | } 64 | templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) 65 | if templ_7745c5c3_Err != nil { 66 | return templ_7745c5c3_Err 67 | } 68 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") 69 | if templ_7745c5c3_Err != nil { 70 | return templ_7745c5c3_Err 71 | } 72 | return nil 73 | }) 74 | } 75 | 76 | var _ = templruntime.GeneratedTemplate 77 | -------------------------------------------------------------------------------- /public/js/input.js: -------------------------------------------------------------------------------- 1 | let inputSequence = []; 2 | let leaderMode = false; 3 | 4 | const mappingsArray = JSON.parse( 5 | document.getElementById("mappings").textContent, 6 | ); 7 | const userSettings = JSON.parse( 8 | document.getElementById("userSettings").textContent, 9 | ); 10 | 11 | const keymaps = parseMappings(mappingsArray); 12 | let leaderKey = userSettings.LeaderKey; 13 | 14 | if (leaderKey.length !== 1) { 15 | setStatus( 16 | "Invalid leader key. Please set a single character leader key in your settings. Using default leader key.", 17 | ); 18 | leaderKey = " "; 19 | } 20 | 21 | document.addEventListener("keydown", (event) => { 22 | const key = event.key; 23 | setStatus(""); 24 | 25 | // ignore keypresses when an input is focused 26 | const activeElement = document.activeElement; 27 | if (activeElement && activeElement.tagName.toLowerCase() === "input") { 28 | return; 29 | } 30 | 31 | if (key === leaderKey) { 32 | setStatus("Listening for key map..."); 33 | leaderMode = true; 34 | inputSequence = []; 35 | return; 36 | } 37 | 38 | if (leaderMode) { 39 | inputSequence.push(key); 40 | 41 | const longestSequence = Math.max( 42 | ...Object.keys(keymaps).map((s) => s.length), 43 | ); 44 | 45 | if (inputSequence.length > longestSequence) { 46 | inputSequence.shift(); 47 | } 48 | 49 | const inputString = inputSequence.join(""); 50 | setStatus(inputString); 51 | 52 | for (const mapping in keymaps) { 53 | if (inputString.endsWith(mapping)) { 54 | try { 55 | url = new URL(keymaps[mapping]); 56 | window.location.href = url; 57 | setStatus(mapping + " → " + keymaps[mapping]); 58 | } catch (e) { 59 | setStatus("Invalid URL: " + keymaps[mapping]); 60 | break; 61 | } 62 | inputSequence = []; 63 | leaderMode = false; 64 | break; 65 | } 66 | } 67 | 68 | // If no match found, exit leader mode 69 | if ( 70 | !Object.keys(keymaps).some((mapping) => 71 | mapping.startsWith(inputString), 72 | ) 73 | ) { 74 | setStatus("No matching key map found for " + inputString); 75 | leaderMode = false; 76 | } 77 | } 78 | }); 79 | 80 | // default mappings 81 | // 'esc' clears the input sequence and unfocuses any input fields 82 | document.addEventListener("keydown", (event) => { 83 | if (event.key === "Escape") { 84 | event.preventDefault(); 85 | inputSequence = []; 86 | leaderMode = false; 87 | document.activeElement.blur(); 88 | } 89 | }); 90 | 91 | // 'i' focuses the search input - i.e. 'insert mode' 92 | document.addEventListener("keydown", (event) => { 93 | const activeElement = document.activeElement; 94 | if (activeElement && activeElement.tagName.toLowerCase() === "input") { 95 | return; 96 | } 97 | 98 | if (event.key === "i" && !leaderMode) { 99 | event.preventDefault(); 100 | document.getElementById("search").focus(); 101 | document.getElementById("search").value = ""; 102 | document 103 | .getElementById("search") 104 | .addEventListener("keydown", (event) => { 105 | if (event.key === "Escape") { 106 | document.getElementById("search").blur(); 107 | } 108 | event.stopPropagation(); 109 | }); 110 | } 111 | }); 112 | 113 | // Parses an array of mappings objects into a dictionary 114 | function parseMappings(mappingsArray) { 115 | const mappings = {}; 116 | mappingsArray.forEach((mapping) => { 117 | mappings[mapping["Keymap"]] = mapping["MapsTo"]; 118 | }); 119 | return mappings; 120 | } 121 | 122 | function setStatus(status) { 123 | document.getElementById("status").textContent = status; 124 | } 125 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | on: 3 | push: 4 | 5 | env: 6 | REGISTRY: ghcr.io 7 | IMAGE_NAME: scottmckendry/mnemstart 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 16 | - name: Setup Go 17 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 18 | with: 19 | go-version: 1.25 20 | - name: Test 21 | run: go run gotest.tools/gotestsum@latest --junitfile unit-tests.xml --format pkgname 22 | 23 | - name: Test summary 24 | uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2 25 | with: 26 | paths: unit-tests.xml 27 | if: always() 28 | 29 | release-please: 30 | name: Release 31 | runs-on: ubuntu-latest 32 | needs: [test] 33 | if: github.ref == 'refs/heads/main' 34 | outputs: 35 | release_created: ${{ steps.release-please.outputs.release_created }} 36 | tag_name: ${{ steps.release-please.outputs.tag_name }} 37 | steps: 38 | - uses: googleapis/release-please-action@c2a5a2bd6a758a0937f1ddb1e8950609867ed15c # v4 39 | id: release-please 40 | with: 41 | token: ${{ secrets.GITHUB_TOKEN }} 42 | release-type: go 43 | 44 | build-image: 45 | name: Publish 46 | needs: [release-please] 47 | if: needs.release-please.outputs.release_created == 'true' 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 52 | 53 | # Install the cosign tool except on PR 54 | # https://github.com/sigstore/cosign-installer 55 | - name: Install cosign 56 | uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 57 | with: 58 | cosign-release: "v2.2.2" 59 | 60 | # Set up BuildKit Docker container builder to be able to build 61 | # multi-platform images and export cache 62 | # https://github.com/docker/setup-buildx-action 63 | - name: Set up Docker Buildx 64 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 65 | 66 | # Login against a Docker registry except on PR 67 | # https://github.com/docker/login-action 68 | - name: Log into registry ${{ env.REGISTRY }} 69 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 70 | with: 71 | registry: ${{ env.REGISTRY }} 72 | username: ${{ github.actor }} 73 | password: ${{ secrets.GITHUB_TOKEN }} 74 | 75 | # Extract metadata (tags, labels) for Docker 76 | # https://github.com/docker/metadata-action 77 | - name: Extract Docker metadata 78 | id: meta 79 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5 80 | with: 81 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 82 | tags: | 83 | type=raw,value=latest 84 | type=raw,value=${{ needs.release-please.outputs.tag_name }} 85 | 86 | # Build and push Docker image with Buildx (don't push on PR) 87 | # https://github.com/docker/build-push-action 88 | - name: Build and push Docker image 89 | id: build-and-push 90 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 91 | with: 92 | context: . 93 | push: ${{ github.event_name != 'pull_request' }} 94 | tags: ${{ steps.meta.outputs.tags }} 95 | labels: ${{ steps.meta.outputs.labels }} 96 | cache-from: type=gha 97 | cache-to: type=gha,mode=max 98 | 99 | update-docker-image: 100 | needs: [build-image] 101 | name: Pull image & restart container 102 | runs-on: self-hosted 103 | steps: 104 | - run: cd /srv/docker/mnemstart && docker compose pull && docker compose up -d --force-recreate 105 | -------------------------------------------------------------------------------- /views/home_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package views 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "github.com/markbates/goth" 13 | "github.com/scottmckendry/mnemstart/data" 14 | ) 15 | 16 | func Home(user goth.User, settings *data.UserSettings, mappings []data.Mapping) templ.Component { 17 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 18 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 19 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 20 | return templ_7745c5c3_CtxErr 21 | } 22 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 23 | if !templ_7745c5c3_IsBuffer { 24 | defer func() { 25 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 26 | if templ_7745c5c3_Err == nil { 27 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 28 | } 29 | }() 30 | } 31 | ctx = templ.InitializeContext(ctx) 32 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 33 | if templ_7745c5c3_Var1 == nil { 34 | templ_7745c5c3_Var1 = templ.NopComponent 35 | } 36 | ctx = templ.ClearChildren(ctx) 37 | templ_7745c5c3_Err = templ.JSONScript("userSettings", settings).Render(ctx, templ_7745c5c3_Buffer) 38 | if templ_7745c5c3_Err != nil { 39 | return templ_7745c5c3_Err 40 | } 41 | templ_7745c5c3_Err = templ.JSONScript("mappings", mappings).Render(ctx, templ_7745c5c3_Buffer) 42 | if templ_7745c5c3_Err != nil { 43 | return templ_7745c5c3_Err 44 | } 45 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 46 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 47 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 48 | if !templ_7745c5c3_IsBuffer { 49 | defer func() { 50 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 51 | if templ_7745c5c3_Err == nil { 52 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 53 | } 54 | }() 55 | } 56 | ctx = templ.InitializeContext(ctx) 57 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") 58 | if templ_7745c5c3_Err != nil { 59 | return templ_7745c5c3_Err 60 | } 61 | templ_7745c5c3_Err = Clock().Render(ctx, templ_7745c5c3_Buffer) 62 | if templ_7745c5c3_Err != nil { 63 | return templ_7745c5c3_Err 64 | } 65 | templ_7745c5c3_Err = Search(settings).Render(ctx, templ_7745c5c3_Buffer) 66 | if templ_7745c5c3_Err != nil { 67 | return templ_7745c5c3_Err 68 | } 69 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") 70 | if templ_7745c5c3_Err != nil { 71 | return templ_7745c5c3_Err 72 | } 73 | return nil 74 | }) 75 | templ_7745c5c3_Err = Page(true, user).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 76 | if templ_7745c5c3_Err != nil { 77 | return templ_7745c5c3_Err 78 | } 79 | return nil 80 | }) 81 | } 82 | 83 | func Clock() templ.Component { 84 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 85 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 86 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 87 | return templ_7745c5c3_CtxErr 88 | } 89 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 90 | if !templ_7745c5c3_IsBuffer { 91 | defer func() { 92 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 93 | if templ_7745c5c3_Err == nil { 94 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 95 | } 96 | }() 97 | } 98 | ctx = templ.InitializeContext(ctx) 99 | templ_7745c5c3_Var3 := templ.GetChildren(ctx) 100 | if templ_7745c5c3_Var3 == nil { 101 | templ_7745c5c3_Var3 = templ.NopComponent 102 | } 103 | ctx = templ.ClearChildren(ctx) 104 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") 105 | if templ_7745c5c3_Err != nil { 106 | return templ_7745c5c3_Err 107 | } 108 | return nil 109 | }) 110 | } 111 | 112 | var _ = templruntime.GeneratedTemplate 113 | -------------------------------------------------------------------------------- /data/data_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/markbates/goth" 8 | ) 9 | 10 | var db, _ = NewLibSqlDatabase("file:test.db") 11 | var store = NewStore(db) 12 | var userEmail = "john@example.com" 13 | 14 | func TestCreateOrUpdateUser(t *testing.T) { 15 | user := goth.User{ 16 | Provider: "github", 17 | Email: userEmail, 18 | Name: "John Doe", 19 | UserID: "1234", 20 | } 21 | 22 | err := store.CreateOrUpdateUser(user) 23 | if err != nil { 24 | t.Errorf("Error creating or updating user: %v", err) 25 | } 26 | 27 | mnemstart_user := User{ 28 | Email: userEmail, 29 | } 30 | 31 | err = getUser(db, &mnemstart_user) 32 | if err != nil { 33 | t.Errorf("Error getting user: %v", err) 34 | } 35 | 36 | if mnemstart_user.GithubID != "1234" { 37 | t.Errorf("Expected GithubID to be 1234, got %s", mnemstart_user.GithubID) 38 | } 39 | 40 | user2 := goth.User{ 41 | Provider: "discord", 42 | Email: mnemstart_user.Email, 43 | UserID: "5678", 44 | } 45 | 46 | err = store.CreateOrUpdateUser(user2) 47 | if err != nil { 48 | t.Errorf("Error creating or updating user: %v", err) 49 | } 50 | 51 | err = getUser(db, &mnemstart_user) 52 | if err != nil { 53 | t.Errorf("Error getting user: %v", err) 54 | } 55 | 56 | if mnemstart_user.DiscordID != "5678" { 57 | t.Errorf("Expected DiscordID to be 5678, got %s", mnemstart_user.DiscordID) 58 | } 59 | } 60 | 61 | func TestGetUser(t *testing.T) { 62 | user := User{ 63 | Email: userEmail, 64 | } 65 | 66 | err := getUser(db, &user) 67 | 68 | if err != nil { 69 | t.Errorf("Error getting user: %v", err) 70 | } 71 | 72 | if user.GithubID != "1234" { 73 | t.Errorf("Expected GithubID to be 1234, got %s", user.GithubID) 74 | } 75 | 76 | if user.DiscordID != "5678" { 77 | t.Errorf("Expected DiscordID to be 5678, got %s", user.DiscordID) 78 | } 79 | } 80 | 81 | func TestAddMapping(t *testing.T) { 82 | _, err := db.Exec("DELETE FROM mappings") 83 | 84 | mapping := Mapping{ 85 | Keymap: "gh", 86 | MapsTo: "https://github.com", 87 | } 88 | 89 | err = store.AddMapping(userEmail, mapping.Keymap, mapping.MapsTo) 90 | if err != nil { 91 | t.Errorf("Error adding mapping: %v", err) 92 | } 93 | 94 | mappings := store.GetMappings(userEmail) 95 | if err != nil { 96 | t.Errorf("Error getting mappings: %v", err) 97 | } 98 | 99 | if len(mappings) != 1 { 100 | t.Errorf("Expected 1 mapping, got %d", len(mappings)) 101 | } 102 | 103 | if mappings[0].Keymap != mapping.Keymap { 104 | t.Errorf("Expected keymap to be %s, got %s", mapping.Keymap, mappings[0].Keymap) 105 | } 106 | 107 | if mappings[0].MapsTo != mapping.MapsTo { 108 | t.Errorf("Expected maps_to to be %s, got %s", mapping.MapsTo, mappings[0].MapsTo) 109 | } 110 | } 111 | 112 | func TestGetMappings(t *testing.T) { 113 | mappings := store.GetMappings(userEmail) 114 | 115 | if len(mappings) != 1 { 116 | t.Errorf("Expected 1 mapping, got %d", len(mappings)) 117 | } 118 | 119 | if mappings[0].Keymap != "gh" { 120 | t.Errorf("Expected keymap to be gh, got %s", mappings[0].Keymap) 121 | } 122 | 123 | if mappings[0].MapsTo != "https://github.com" { 124 | t.Errorf("Expected maps_to to be https://github.com, got %s", mappings[0].MapsTo) 125 | } 126 | } 127 | 128 | func TestUpdateMapping(t *testing.T) { 129 | mappings := store.GetMappings(userEmail) 130 | if len(mappings) != 1 { 131 | t.Errorf("Expected 1 mapping, got %d", len(mappings)) 132 | } 133 | 134 | mapping := mappings[0] 135 | mapping.MapsTo = "https://gitlab.com" 136 | 137 | err := store.UpdateMapping(strconv.Itoa(mapping.ID), userEmail, mapping.Keymap, mapping.MapsTo) 138 | if err != nil { 139 | t.Errorf("Error updating mapping: %v", err) 140 | } 141 | 142 | mappings = store.GetMappings(userEmail) 143 | if len(mappings) != 1 { 144 | t.Errorf("Expected 1 mapping, got %d", len(mappings)) 145 | } 146 | 147 | if mappings[0].MapsTo != "https://gitlab.com" { 148 | t.Errorf("Expected maps_to to be https://gitlab.com, got %s", mappings[0].MapsTo) 149 | } 150 | } 151 | 152 | func TestDeleteMapping(t *testing.T) { 153 | mappings := store.GetMappings(userEmail) 154 | if len(mappings) != 1 { 155 | t.Errorf("Expected 1 mapping, got %d", len(mappings)) 156 | } 157 | 158 | mapping := mappings[0] 159 | 160 | err := store.DeleteMapping(strconv.Itoa(mapping.ID), userEmail) 161 | if err != nil { 162 | t.Errorf("Error deleting mapping: %v", err) 163 | } 164 | 165 | mappings = store.GetMappings(userEmail) 166 | if len(mappings) != 0 { 167 | t.Errorf("Expected 0 mappings, got %d", len(mappings)) 168 | } 169 | } 170 | 171 | func TestUpdateUserSettings(t *testing.T) { 172 | user := User{ 173 | Email: userEmail, 174 | } 175 | 176 | err := getUser(db, &user) 177 | if err != nil { 178 | t.Errorf("Error getting user: %v", err) 179 | } 180 | 181 | settings := UserSettings{ 182 | SearchEngine: "test_search_engine", 183 | LeaderKey: "test_leader_key", 184 | } 185 | 186 | err = store.UpdateUserSettings(user.Email, &settings) 187 | if err != nil { 188 | t.Errorf("Error updating user settings: %v", err) 189 | } 190 | 191 | err = getUser(db, &user) 192 | if err != nil { 193 | t.Errorf("Error getting user: %v", err) 194 | } 195 | 196 | returnedSettings := store.GetUserSettings(user.Email) 197 | if returnedSettings.SearchEngine != settings.SearchEngine { 198 | t.Errorf( 199 | "Expected search engine to be %s, got %s", 200 | settings.SearchEngine, 201 | returnedSettings.SearchEngine, 202 | ) 203 | } 204 | 205 | if returnedSettings.LeaderKey != settings.LeaderKey { 206 | t.Errorf( 207 | "Expected leader key to be %s, got %s", 208 | settings.LeaderKey, 209 | returnedSettings.LeaderKey, 210 | ) 211 | } 212 | } 213 | 214 | func TestGetUserSettings(t *testing.T) { 215 | user := User{ 216 | Email: userEmail, 217 | } 218 | 219 | settings := store.GetUserSettings(user.Email) 220 | if settings.SearchEngine != "test_search_engine" { 221 | t.Errorf("Expected search engine to be test_search_engine, got %s", settings.SearchEngine) 222 | } 223 | 224 | if settings.LeaderKey != "test_leader_key" { 225 | t.Errorf("Expected leader key to be test_leader_key, got %s", settings.LeaderKey) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /views/page_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package views 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import "github.com/markbates/goth" 12 | import "fmt" 13 | 14 | func Page(nav bool, user goth.User) templ.Component { 15 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 16 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 17 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 18 | return templ_7745c5c3_CtxErr 19 | } 20 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 21 | if !templ_7745c5c3_IsBuffer { 22 | defer func() { 23 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 24 | if templ_7745c5c3_Err == nil { 25 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 26 | } 27 | }() 28 | } 29 | ctx = templ.InitializeContext(ctx) 30 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 31 | if templ_7745c5c3_Var1 == nil { 32 | templ_7745c5c3_Var1 = templ.NopComponent 33 | } 34 | ctx = templ.ClearChildren(ctx) 35 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "mnemstart🧠\">") 36 | if templ_7745c5c3_Err != nil { 37 | return templ_7745c5c3_Err 38 | } 39 | if nav { 40 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") 54 | if templ_7745c5c3_Err != nil { 55 | return templ_7745c5c3_Err 56 | } 57 | } 58 | templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) 59 | if templ_7745c5c3_Err != nil { 60 | return templ_7745c5c3_Err 61 | } 62 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") 63 | if templ_7745c5c3_Err != nil { 64 | return templ_7745c5c3_Err 65 | } 66 | return nil 67 | }) 68 | } 69 | 70 | func Help() templ.Component { 71 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 72 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 73 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 74 | return templ_7745c5c3_CtxErr 75 | } 76 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 77 | if !templ_7745c5c3_IsBuffer { 78 | defer func() { 79 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 80 | if templ_7745c5c3_Err == nil { 81 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 82 | } 83 | }() 84 | } 85 | ctx = templ.InitializeContext(ctx) 86 | templ_7745c5c3_Var3 := templ.GetChildren(ctx) 87 | if templ_7745c5c3_Var3 == nil { 88 | templ_7745c5c3_Var3 = templ.NopComponent 89 | } 90 | ctx = templ.ClearChildren(ctx) 91 | templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 92 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 93 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 94 | if !templ_7745c5c3_IsBuffer { 95 | defer func() { 96 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 97 | if templ_7745c5c3_Err == nil { 98 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 99 | } 100 | }() 101 | } 102 | ctx = templ.InitializeContext(ctx) 103 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
KeyAction
?Open this help dialog
EscClose this and any other open dialogs
iFocus the search input
alt + sOpen settings
alt + mOpen mappings
") 104 | if templ_7745c5c3_Err != nil { 105 | return templ_7745c5c3_Err 106 | } 107 | return nil 108 | }) 109 | templ_7745c5c3_Err = Modal("Default Shortcuts", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) 110 | if templ_7745c5c3_Err != nil { 111 | return templ_7745c5c3_Err 112 | } 113 | return nil 114 | }) 115 | } 116 | 117 | var _ = templruntime.GeneratedTemplate 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧠 mnemstart 2 | 3 | **_Mnemstart (pronounced "mem-start" as in mnemonic) is a minimal browser start page built around vim-inspired mnemonic shortcuts._** 4 | 5 | ![demo](https://github.com/user-attachments/assets/06b09770-a858-49fe-88e9-60d5c3914e43) 6 | 7 | ## 🚀 Quick Start 8 | 9 | Mnemstart is available for you to use at [start.scottmckendry.tech](https://start.scottmckendry.tech). Sign in with one of the following providers: 10 | 11 | - GitHub 12 | - Discord 13 | - Google 14 | - GitLab 15 | 16 | Hover over the `<` icon in the top right corder to reveal _mappings_, _settings_ and _logout_ buttons. 17 | 18 | It's recommended to use mnemstart in combination with a new tab extension to replace your browser's default new tab page. Some popular options include: 19 | 20 | - [New Tab Redirect](https://github.com/jimschubert/NewTab-Redirect) 21 | - [Custom New Tab Url](https://github.com/zach-adams/simple-new-tab-url) 22 | 23 | ### ⌨️ Default Shortcuts 24 | 25 | - `?` - Show default shortcuts 26 | - `i` - Reveal search bar 27 | - `esc` - Clear shortcut, un-focus search bar and close modals 28 | - `alt+m` - Open mappings 29 | - `alt+s` - Open settings 30 | 31 | ### 🪄 Custom Shortcuts 32 | 33 | Shortcuts are chorded. This means you don't have to play Twister with your fingers to access all your bookmarks. Instead, you can press the leader key (default `SPACE`) followed by one or more keys in sequence (your mapping). 34 | 35 | You can add custom shortcuts by clicking the _mappings_ button and entering a keymap and URL. The keymap can be one or more characters long, with the intention of being easy to remember. For example, you could map `gh` to `https://github.com`, `r` to `https://reddit.com` and `yt` to `https://youtube.com`. 36 | 37 | > [!IMPORTANT] 38 | > Make sure you don't create conflicting maps. If you map `g` to `https://google.com` and `gh` to `https://github.com`, the `gh` mapping will never be accessible because `g` is a prefix of `gh`. 39 | > A non-conflicting example would be mapping `gg` to `https://google.com` and `gh` to `https://github.com`. 40 | 41 | Any custom mappings will **always** be prefixed with the leader key, which is `SPACE` by default. For example, if you add a mapping with the key `g`, you will need to press `SPACE` then `g` to navigate to the URL. 42 | 43 | ## 🔒Self-Hosting 44 | 45 | Some reasons you might want to self-host mnemstart: 46 | 47 | - **Latency**: mnemstart is blazing fast. However, the mnemstart server is geographically located in New Zealand. If you're not in New Zealand, you might experience some latency. Self-hosting mnemstart will allow you to run the server closer to you, or even on your local network. 48 | - **Privacy**: By design, mnemstart stores your email address and name, as well as any custom mappings and settings you provide. None of this information is shared with third parties, but you might still prefer to host it yourself. 49 | 50 | Luckily, self-hosting mnemstart is easy. The whole application (including the database) is contained in a single Docker container. You can run it on any machine that has Docker installed. 51 | 52 | > [!IMPORTANT] 53 | > To self-host mnemstart, you will need to register a new OAuth application with at least one of the supported providers: 54 | > 55 | > - [GitHub](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) 56 | > - [Discord](https://discord.com/developers/docs/topics/oauth2) 57 | > - [Google](https://developers.google.com/identity/protocols/oauth2) 58 | > - [GitLab](https://docs.gitlab.com/ee/integration/oauth_provider.html) 59 | 60 | ### 🐋 Using Docker Compose 61 | 62 | 1. Register a new OAuth application with GitHub and/or Discord. You will need to provide a callback URL in the format `https://your-domain.com[:PORT]/auth/[provider]/callback` where `[provider]` is either `github` or `discord`. Make a note of the client ID and client secret. 63 | 2. Create an empty directory to store the configuration file and database. 64 | 3. In the empty directory, create an `.env` file with the following contents: 65 | 66 | ```env 67 | # Required - at least one OAuth provider is required 68 | GITHUB_CLIENT_ID=your-github-client-id 69 | GITHUB_CLIENT_SECRET=your-github-client-secret 70 | DISCORD_CLIENT_ID=your-discord-client-id 71 | DISCORD_CLIENT_SECRET=your-discord-client-secret 72 | GOOGLE_CLIENT_ID=your-google-client-id 73 | GOOGLE_CLIENT_SECRET=your-google-client-secret 74 | GITLAB_CLIENT_ID=your-gitlab-client-id 75 | GITLAB_CLIENT_SECRET=your-gitlab-client-secret 76 | 77 | # Optional 78 | PUBLIC_HOST=https://your-domain.com # Defaults to http://localhost 79 | PORT=3000 # Defaults to 3000 80 | SEND_PORT_IN_CALLBACK=true # Set to false if you're using a reverse proxy or 80/443 as the port etc. 81 | DATABASE_URL=file:mnemstart.db # Path to the SQLite database file 82 | COOKIES_AUTH_SECRET=your-secret-key # Random string used for cookie encryption 83 | COOKIES_AUTH_AGE_IN_SECONDS=2592000 # Cookie expiry time in seconds (default 30 days) 84 | COOKIES_AUTH_SECURE=false # Set to true if using HTTPS 85 | COOKIES_AUTH_HTTP_ONLY=true # Set to false if you want to access cookies from JavaScript 86 | ``` 87 | 88 | 3. In the empty directory, create a file called `docker-compose.yml` with the following contents: 89 | 90 | ```yml 91 | services: 92 | mnemstart: 93 | image: ghcr.io/scottmckendry/mnemstart 94 | ports: 95 | - "3000:3000" 96 | env_file: 97 | - .env 98 | volumes: 99 | # read-only access to the host's timezone 100 | - /etc/localtime:/etc/localtime:ro 101 | # empty directory to store user sessions and SQLite database 102 | - ./data/:/app/data 103 | # read-only access to the .env file 104 | - ./.env:/app/.env:ro 105 | restart: unless-stopped 106 | ``` 107 | 108 | 4. Run `docker-compose up -d` to start the container in the background. 109 | 110 | ## 🧑‍💻 Development 111 | 112 | ### 🐋 Using Docker (Recommended) 113 | 114 | 1. Clone the repository and navigate to the root directory. 115 | 2. Create an `.env` file and populate at least one OAuth provider. See the example above in **Self-Hosting**. 116 | 3. Run `docker-compose up` to start the development server. The application will be available at `http://localhost:3000`. 117 | 118 | ### 🚀 Without Docker 119 | 120 | **Dependencies:** 121 | 122 | - Go 1.24 or later 123 | - air (`go install github.com/air-verse/air@latest`) - for live reloading 124 | - templ (`go install github.com/a-h/templ@latest`) 125 | 126 | **Steps:** 127 | 128 | 1. Clone the repository and navigate to the root directory. 129 | 2. Create an `.env` file with an OAuth provider. See the example above in **Self-Hosting**. 130 | 3. Run `templ generate` to ensure all `_templ.go` files are up to date. 131 | 4. Run `air` to start the development server. The application will be available at `http://localhost:3000`. 132 | 133 | ## 🤝 Contributing 134 | 135 | Contributions are welcome! Please feel free to open an issue or pull request if you have any suggestions or improvements. 136 | -------------------------------------------------------------------------------- /views/settings_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package views 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "github.com/markbates/goth" 13 | "github.com/scottmckendry/mnemstart/data" 14 | ) 15 | 16 | func Settings(user goth.User, settings *data.UserSettings) templ.Component { 17 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 18 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 19 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 20 | return templ_7745c5c3_CtxErr 21 | } 22 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 23 | if !templ_7745c5c3_IsBuffer { 24 | defer func() { 25 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 26 | if templ_7745c5c3_Err == nil { 27 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 28 | } 29 | }() 30 | } 31 | ctx = templ.InitializeContext(ctx) 32 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 33 | if templ_7745c5c3_Var1 == nil { 34 | templ_7745c5c3_Var1 = templ.NopComponent 35 | } 36 | ctx = templ.ClearChildren(ctx) 37 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 38 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 39 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 40 | if !templ_7745c5c3_IsBuffer { 41 | defer func() { 42 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 43 | if templ_7745c5c3_Err == nil { 44 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 45 | } 46 | }() 47 | } 48 | ctx = templ.InitializeContext(ctx) 49 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") 129 | if templ_7745c5c3_Err != nil { 130 | return templ_7745c5c3_Err 131 | } 132 | return nil 133 | }) 134 | templ_7745c5c3_Err = Modal("Settings", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 135 | if templ_7745c5c3_Err != nil { 136 | return templ_7745c5c3_Err 137 | } 138 | return nil 139 | }) 140 | } 141 | 142 | var _ = templruntime.GeneratedTemplate 143 | -------------------------------------------------------------------------------- /data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/markbates/goth" 9 | ) 10 | 11 | type Storage struct { 12 | db *sql.DB 13 | } 14 | 15 | type User struct { 16 | ID int 17 | Name string 18 | Email string 19 | DiscordID string 20 | GithubID string 21 | GoogleID string 22 | GitlabID string 23 | } 24 | 25 | type Mapping struct { 26 | ID int 27 | Keymap string 28 | MapsTo string 29 | } 30 | 31 | type UserSettings struct { 32 | SearchEngine string 33 | LeaderKey string 34 | ShowSuggestions bool 35 | } 36 | 37 | func NewStore(db *sql.DB) *Storage { 38 | return &Storage{ 39 | db: db, 40 | } 41 | } 42 | 43 | func (s *Storage) CreateOrUpdateUser(gothUser goth.User) error { 44 | user := buildUserFromGothUser(gothUser) 45 | err := getUser(s.db, user) 46 | if err != nil { 47 | err = createUser(s.db, user) 48 | if err != nil { 49 | return fmt.Errorf("Error creating user: %v", err) 50 | } 51 | } 52 | 53 | appendProviderID(gothUser.Provider, gothUser.UserID, user) 54 | err = updateUser(s.db, user) 55 | if err != nil { 56 | return fmt.Errorf("Error updating user: %v", err) 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func appendProviderID(provider string, userId string, user *User) { 63 | switch provider { 64 | case "discord": 65 | user.DiscordID = userId 66 | case "github": 67 | user.GithubID = userId 68 | case "google": 69 | user.GoogleID = userId 70 | case "gitlab": 71 | user.GitlabID = userId 72 | } 73 | } 74 | 75 | func getUser(db *sql.DB, user *User) error { 76 | row := db.QueryRow( 77 | "SELECT id, name, email, discord_id, github_id, google_id, gitlab_id FROM users WHERE email = ?", 78 | user.Email, 79 | ) 80 | err := row.Scan( 81 | &user.ID, 82 | &user.Name, 83 | &user.Email, 84 | &user.DiscordID, 85 | &user.GithubID, 86 | &user.GoogleID, 87 | &user.GitlabID, 88 | ) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func createUser(db *sql.DB, user *User) error { 97 | _, err := db.Exec( 98 | "INSERT INTO users (name, email, discord_id, github_id, google_id, gitlab_id) VALUES (?, ?, ?, ?, ?, ?)", 99 | user.Name, 100 | user.Email, 101 | user.DiscordID, 102 | user.GithubID, 103 | user.GoogleID, 104 | user.GitlabID, 105 | ) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func updateUser(db *sql.DB, user *User) error { 114 | _, err := db.Exec(` 115 | UPDATE users 116 | SET 117 | name = CASE 118 | WHEN name IS NULL THEN ? 119 | ELSE name 120 | END, 121 | discord_id = ?, 122 | github_id = ?, 123 | google_id = ?, 124 | gitlab_id = ? 125 | WHERE email = ? 126 | `, 127 | user.Name, 128 | user.DiscordID, 129 | user.GithubID, 130 | user.GoogleID, 131 | user.GitlabID, 132 | user.Email, 133 | ) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func buildUserFromGothUser(gothUser goth.User) *User { 142 | user := &User{ 143 | Name: gothUser.Name, 144 | Email: gothUser.Email, 145 | } 146 | 147 | appendProviderID(gothUser.Provider, gothUser.UserID, user) 148 | return user 149 | } 150 | 151 | func (s *Storage) GetMappings(email string) []Mapping { 152 | mappings := []Mapping{} 153 | rows, err := s.db.Query( 154 | `SELECT mappings.id, keymap, maps_to 155 | FROM mappings 156 | INNER JOIN users 157 | ON mappings.user_id = users.id 158 | WHERE users.email = ?`, 159 | email, 160 | ) 161 | if err != nil { 162 | slog.Error("failed to get mappings", "error", err) 163 | return nil 164 | } 165 | defer rows.Close() 166 | 167 | for rows.Next() { 168 | mapping := Mapping{} 169 | err = rows.Scan(&mapping.ID, &mapping.Keymap, &mapping.MapsTo) 170 | if err != nil { 171 | slog.Error("failed to scan mapping row", "error", err) 172 | return nil 173 | } 174 | 175 | mappings = append(mappings, mapping) 176 | } 177 | 178 | return mappings 179 | } 180 | 181 | func (s *Storage) GetMapping(mappingID string, email string) *Mapping { 182 | mapping := &Mapping{} 183 | row := s.db.QueryRow( 184 | `SELECT mappings.id, keymap, maps_to 185 | FROM mappings 186 | INNER JOIN users 187 | ON mappings.user_id = users.id 188 | WHERE mappings.id = ? 189 | AND users.email = ?`, 190 | mappingID, 191 | email, 192 | ) 193 | err := row.Scan(&mapping.ID, &mapping.Keymap, &mapping.MapsTo) 194 | if err != nil { 195 | slog.Error("failed to get mapping", "error", err, "mappingID", mappingID) 196 | return nil 197 | } 198 | 199 | return mapping 200 | } 201 | 202 | func (s *Storage) AddMapping(email string, keymap string, mapsTo string) error { 203 | _, err := s.db.Exec( 204 | `INSERT INTO mappings (user_id, keymap, maps_to) 205 | VALUES ( 206 | (SELECT id FROM users WHERE email = ?), 207 | ?, 208 | ? 209 | )`, 210 | email, 211 | keymap, 212 | mapsTo, 213 | ) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | return nil 219 | } 220 | 221 | func (s *Storage) UpdateMapping( 222 | mappingID string, 223 | email string, 224 | keymap string, 225 | mapsTo string, 226 | ) error { 227 | _, err := s.db.Exec( 228 | `UPDATE mappings 229 | SET keymap = ?, maps_to = ? 230 | WHERE id = ? 231 | AND user_id = (SELECT id FROM users WHERE email = ?)`, 232 | keymap, 233 | mapsTo, 234 | mappingID, 235 | email, 236 | ) 237 | if err != nil { 238 | return err 239 | } 240 | 241 | return nil 242 | } 243 | 244 | func (s *Storage) DeleteMapping(mappingID string, email string) error { 245 | _, err := s.db.Exec( 246 | `DELETE FROM mappings 247 | WHERE id = ? 248 | AND user_id = (SELECT id FROM users WHERE email = ?)`, 249 | mappingID, 250 | email, 251 | ) 252 | if err != nil { 253 | return err 254 | } 255 | 256 | return nil 257 | } 258 | 259 | func (s *Storage) GetUserSettings(email string) *UserSettings { 260 | settings := &UserSettings{} 261 | rows, err := s.db.Query( 262 | `SELECT setting_key, setting_value 263 | FROM user_settings 264 | INNER JOIN users 265 | ON user_settings.user_id = users.id 266 | WHERE users.email = ?`, 267 | email, 268 | ) 269 | if err != nil { 270 | slog.Error("failed to get user settings", "error", err, "email", email) 271 | return nil 272 | } 273 | defer rows.Close() 274 | 275 | for rows.Next() { 276 | var key, value string 277 | err = rows.Scan(&key, &value) 278 | if err != nil { 279 | slog.Error("failed to scan settings row", "error", err) 280 | return nil 281 | } 282 | 283 | switch key { 284 | case "SearchEngine": 285 | settings.SearchEngine = value 286 | case "LeaderKey": 287 | settings.LeaderKey = value 288 | case "ShowSuggestions": 289 | settings.ShowSuggestions = value == "true" 290 | } 291 | } 292 | 293 | return settings 294 | } 295 | 296 | func (s *Storage) UpdateUserSettings(email string, settings *UserSettings) error { 297 | settingsMap := map[string]interface{}{ 298 | "SearchEngine": settings.SearchEngine, 299 | "LeaderKey": settings.LeaderKey, 300 | "ShowSuggestions": fmt.Sprintf("%t", settings.ShowSuggestions), // "true" or "false 301 | } 302 | 303 | for settingKey, settingValue := range settingsMap { 304 | _, err := s.db.Exec( 305 | `Delete from user_settings 306 | WHERE user_id = (SELECT id FROM users WHERE email = ?) 307 | AND setting_key = ?`, 308 | email, 309 | settingKey, 310 | ) 311 | if err != nil { 312 | return err 313 | } 314 | 315 | _, err = s.db.Exec( 316 | `INSERT INTO user_settings (user_id, setting_key, setting_value) 317 | VALUES ( 318 | (SELECT id FROM users WHERE email = ?), 319 | ?, 320 | ? 321 | )`, 322 | email, 323 | settingKey, 324 | settingValue, 325 | ) 326 | if err != nil { 327 | return err 328 | } 329 | } 330 | 331 | return nil 332 | } 333 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= 2 | cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= 3 | github.com/a-h/templ v0.3.898 h1:g9oxL/dmM6tvwRe2egJS8hBDQTncokbMoOFk1oJMX7s= 4 | github.com/a-h/templ v0.3.898/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ= 5 | github.com/a-h/templ v0.3.906 h1:ZUThc8Q9n04UATaCwaG60pB1AqbulLmYEAMnWV63svg= 6 | github.com/a-h/templ v0.3.906/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334= 7 | github.com/a-h/templ v0.3.924 h1:t5gZqTneXqvehpNZsgtnlOscnBboNh9aASBH2MgV/0k= 8 | github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334= 9 | github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= 10 | github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= 11 | github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= 12 | github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 16 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 17 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 18 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 19 | github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= 20 | github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 21 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 22 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 23 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 24 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 25 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 26 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 27 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 28 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 29 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 30 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 31 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 32 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 33 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 34 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 35 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 36 | github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= 37 | github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= 38 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 39 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 40 | github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE= 41 | github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k= 42 | github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ= 43 | github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk= 44 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 45 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 46 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 47 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 50 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 51 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 52 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 53 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 54 | github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= 55 | github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= 56 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 57 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 58 | golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= 59 | golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 60 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 61 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 62 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 63 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 64 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 66 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 67 | golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= 68 | golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 69 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 70 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 71 | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= 72 | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 73 | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= 74 | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 75 | modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= 76 | modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 77 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 78 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 79 | modernc.org/goabi0 v0.0.3 h1:y81b9r3asCh6Xtse6Nz85aYGB0cG3M3U6222yap1KWI= 80 | modernc.org/goabi0 v0.0.3/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 81 | modernc.org/libc v1.66.0 h1:eoFuDb1ozurUY5WSWlgvxHp0FuL+AncMwNjFqGYMJPQ= 82 | modernc.org/libc v1.66.0/go.mod h1:AiZxInURfEJx516LqEaFcrC+X38rt9G7+8ojIXQKHbo= 83 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 84 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 85 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 86 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 87 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 88 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 89 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 90 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 91 | modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= 92 | modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= 93 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 94 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 95 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 96 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 97 | -------------------------------------------------------------------------------- /views/search_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package views 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "github.com/scottmckendry/mnemstart/data" 13 | ) 14 | 15 | type SearchEngine struct { 16 | URL string 17 | QueryParam string 18 | } 19 | 20 | var searchEngines = map[string]SearchEngine{ 21 | "Google": { 22 | URL: "https://www.google.com/search", 23 | QueryParam: "q", 24 | }, 25 | "DuckDuckGo": { 26 | URL: "https://duckduckgo.com/", 27 | QueryParam: "q", 28 | }, 29 | "Bing": { 30 | URL: "https://www.bing.com/search", 31 | QueryParam: "q", 32 | }, 33 | "Brave Search": { 34 | URL: "https://search.brave.com/search", 35 | QueryParam: "q", 36 | }, 37 | } 38 | 39 | func Search(settings *data.UserSettings) templ.Component { 40 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 41 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 42 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 43 | return templ_7745c5c3_CtxErr 44 | } 45 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 46 | if !templ_7745c5c3_IsBuffer { 47 | defer func() { 48 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 49 | if templ_7745c5c3_Err == nil { 50 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 51 | } 52 | }() 53 | } 54 | ctx = templ.InitializeContext(ctx) 55 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 56 | if templ_7745c5c3_Var1 == nil { 57 | templ_7745c5c3_Var1 = templ.NopComponent 58 | } 59 | ctx = templ.ClearChildren(ctx) 60 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") 74 | if templ_7745c5c3_Err != nil { 75 | return templ_7745c5c3_Err 76 | } 77 | if settings.ShowSuggestions { 78 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") 92 | if templ_7745c5c3_Err != nil { 93 | return templ_7745c5c3_Err 94 | } 95 | } else { 96 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") 110 | if templ_7745c5c3_Err != nil { 111 | return templ_7745c5c3_Err 112 | } 113 | } 114 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") 128 | if templ_7745c5c3_Err != nil { 129 | return templ_7745c5c3_Err 130 | } 131 | return nil 132 | }) 133 | } 134 | 135 | func Suggestions(suggestions []string, engine string) templ.Component { 136 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 137 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 138 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 139 | return templ_7745c5c3_CtxErr 140 | } 141 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 142 | if !templ_7745c5c3_IsBuffer { 143 | defer func() { 144 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 145 | if templ_7745c5c3_Err == nil { 146 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 147 | } 148 | }() 149 | } 150 | ctx = templ.InitializeContext(ctx) 151 | templ_7745c5c3_Var6 := templ.GetChildren(ctx) 152 | if templ_7745c5c3_Var6 == nil { 153 | templ_7745c5c3_Var6 = templ.NopComponent 154 | } 155 | ctx = templ.ClearChildren(ctx) 156 | for _, suggestion := range suggestions { 157 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") 171 | if templ_7745c5c3_Err != nil { 172 | return templ_7745c5c3_Err 173 | } 174 | var templ_7745c5c3_Var8 string 175 | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(suggestion) 176 | if templ_7745c5c3_Err != nil { 177 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/search.templ`, Line: 63, Col: 16} 178 | } 179 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 180 | if templ_7745c5c3_Err != nil { 181 | return templ_7745c5c3_Err 182 | } 183 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") 184 | if templ_7745c5c3_Err != nil { 185 | return templ_7745c5c3_Err 186 | } 187 | } 188 | return nil 189 | }) 190 | } 191 | 192 | var _ = templruntime.GeneratedTemplate 193 | -------------------------------------------------------------------------------- /public/css/styles.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | * { 8 | margin: 0; 9 | } 10 | 11 | input, 12 | button { 13 | font: inherit; 14 | } 15 | 16 | body { 17 | font-family: "Noto Sans", sans-serif; 18 | font-size: 16px; 19 | line-height: 1.5; 20 | color: #fff; 21 | background-color: #161a1d; 22 | } 23 | 24 | a { 25 | color: #fff; 26 | text-decoration: none; 27 | } 28 | 29 | kbd { 30 | padding: 3px 0; 31 | background-color: #555; 32 | color: #fff; 33 | padding: 0.1rem 0.3rem; 34 | border-radius: 0.25rem; 35 | font-family: monospace; 36 | font-size: 0.9rem; 37 | } 38 | 39 | td { 40 | padding: 3px 0; 41 | } 42 | 43 | .login { 44 | display: flex; 45 | justify-content: center; 46 | align-items: center; 47 | height: 100vh; 48 | } 49 | 50 | .login-container { 51 | border: 1px solid #555; 52 | border-radius: 0.5rem; 53 | padding: 2rem; 54 | width: 300px; 55 | } 56 | 57 | .login-container h2 { 58 | margin-bottom: 1rem; 59 | font-size: 1.5rem; 60 | text-align: center; 61 | } 62 | 63 | .login-buttons { 64 | display: flex; 65 | flex-direction: row; 66 | justify-content: space-between; 67 | padding: 0 0.5rem; 68 | } 69 | 70 | .login-button { 71 | display: flex; 72 | flex-direction: row; 73 | height: 2.5rem; 74 | width: 2.5rem; 75 | border-radius: 0.25rem; 76 | color: #fff; 77 | } 78 | 79 | .button-logo { 80 | font-size: 2rem; 81 | padding: 0.25rem; 82 | flex: initial; 83 | position: relative; 84 | justify-content: center; 85 | align-items: center; 86 | } 87 | 88 | .login-button-text { 89 | display: flex; 90 | justify-content: center; 91 | align-items: center; 92 | flex: 1; 93 | padding-right: 5px; 94 | } 95 | 96 | .github { 97 | background-color: #333; 98 | } 99 | 100 | .discord { 101 | background-color: #7289da; 102 | } 103 | 104 | .google { 105 | background-color: #4285f4; 106 | } 107 | 108 | .gitlab { 109 | background-color: #fc6d26; 110 | } 111 | 112 | /*main-content is a div containing multiple elements, the div should be centered on the page an elements inside stacked on top of one another.*/ 113 | #main-content { 114 | display: flex; 115 | flex-direction: column; 116 | justify-content: center; 117 | align-items: center; 118 | margin-top: 30vh; 119 | } 120 | 121 | #clock { 122 | font-size: 2.5rem; 123 | } 124 | 125 | #date { 126 | font-size: 1rem; 127 | margin-bottom: 1rem; 128 | } 129 | 130 | #status { 131 | position: fixed; 132 | bottom: 0; 133 | left: 0; 134 | padding: 0.5rem; 135 | font-size: 0.75rem; 136 | color: #999; 137 | } 138 | 139 | #search { 140 | background-color: transparent; 141 | text-align: center; 142 | color: #fff; 143 | outline: none; 144 | border: none; 145 | opacity: 0; 146 | transition: all 0.2s ease-in-out; 147 | } 148 | 149 | #suggestions { 150 | display: flex; 151 | justify-content: center; 152 | flex-wrap: wrap; 153 | width: 50vw; 154 | } 155 | 156 | #suggestions a { 157 | padding: 10px 15px; 158 | margin: 10px; 159 | color: #fff; 160 | text-decoration: none; 161 | background-color: #333; 162 | transition: all 0.2s ease-in-out; 163 | border-radius: 0.25rem; 164 | } 165 | 166 | #search:focus { 167 | opacity: 1; 168 | } 169 | 170 | /*nav styles*/ 171 | nav { 172 | position: fixed; 173 | top: 0; 174 | right: 0; 175 | z-index: 1000; 176 | width: 38px; 177 | height: 36px; 178 | overflow: hidden; 179 | transition: all 0.2s ease-in-out; 180 | border-radius: 0 0 3px 0; 181 | } 182 | 183 | nav a, 184 | nav ul { 185 | margin: 0; 186 | padding: 0; 187 | } 188 | 189 | nav:hover { 190 | width: 38px; 191 | height: 250px; 192 | transition: all 0.4s ease-in-out; 193 | } 194 | 195 | nav ul { 196 | list-style-type: none; 197 | margin: 0 0 0 0; 198 | transition: all 0.2s ease-in-out; 199 | } 200 | 201 | nav ul li { 202 | transition: all 0.2s ease-in-out; 203 | } 204 | 205 | .navicon { 206 | padding: 12px 0; 207 | width: 40px; 208 | } 209 | 210 | .navicon:before { 211 | padding: 12px; 212 | } 213 | 214 | nav .bx:before { 215 | font-size: 16px; 216 | } 217 | 218 | .caret-rotate { 219 | transition: all 0.2s ease-in-out; 220 | } 221 | 222 | nav:hover .caret-rotate { 223 | transform: rotate(-90deg); 224 | } 225 | 226 | .settings-container { 227 | display: flex; 228 | flex-direction: column; 229 | } 230 | 231 | .setting { 232 | margin-bottom: 10px; 233 | } 234 | 235 | .tooltip-container { 236 | position: relative; 237 | display: inline-block; 238 | } 239 | 240 | .tooltip-text { 241 | visibility: hidden; 242 | width: 250px; 243 | background-color: #555; 244 | color: #fff; 245 | text-align: center; 246 | border-radius: 6px; 247 | padding: 10px; 248 | position: absolute; 249 | z-index: 1; 250 | bottom: 125%; 251 | left: 50%; 252 | margin-left: -60px; 253 | opacity: 0; 254 | transition: opacity 0.3s; 255 | font-size: 0.75rem; 256 | } 257 | 258 | .tooltip-container:hover .tooltip-text { 259 | visibility: visible; 260 | opacity: 1; 261 | } 262 | 263 | /*modal styles*/ 264 | #modal { 265 | /* Underlay covers entire screen. */ 266 | position: fixed; 267 | top: 0px; 268 | bottom: 0px; 269 | left: 0px; 270 | right: 0px; 271 | background-color: rgba(0, 0, 0, 0.5); 272 | z-index: 1000; 273 | 274 | /* Flexbox centers the .modal-content vertically and horizontally */ 275 | display: flex; 276 | flex-direction: column; 277 | align-items: center; 278 | 279 | /* Animate when opening */ 280 | animation-name: fadeIn; 281 | animation-duration: 150ms; 282 | animation-timing-function: ease; 283 | } 284 | 285 | #modal > .modal-underlay { 286 | /* underlay takes up the entire viewport. This is only 287 | required if you want to click to dismiss the popup */ 288 | position: absolute; 289 | z-index: -1; 290 | top: 0px; 291 | bottom: 0px; 292 | left: 0px; 293 | right: 0px; 294 | } 295 | 296 | #modal > .modal-content { 297 | /* Position visible dialog near the top of the window */ 298 | margin-top: 10vh; 299 | 300 | /* Sizing for visible dialog */ 301 | width: 80%; 302 | max-width: 600px; 303 | 304 | /* Display properties for visible dialog*/ 305 | border-radius: 8px; 306 | box-shadow: 0px 0px 20px 0px rgba(33, 33, 33, 0.5); 307 | background-color: #323130; 308 | padding: 20px; 309 | 310 | /* Animate when opening */ 311 | animation-name: zoomIn; 312 | animation-duration: 150ms; 313 | animation-timing-function: ease; 314 | } 315 | 316 | #modal.closing { 317 | /* Animate when closing */ 318 | animation-name: fadeOut; 319 | animation-duration: 150ms; 320 | animation-timing-function: ease; 321 | } 322 | 323 | #modal.closing > .modal-content { 324 | /* Animate when closing */ 325 | animation-name: zoomOut; 326 | animation-duration: 150ms; 327 | animation-timing-function: ease; 328 | } 329 | 330 | .modal-header { 331 | display: flex; 332 | justify-content: space-between; 333 | align-items: center; 334 | margin-bottom: 20px; 335 | } 336 | 337 | .modal-header h2 { 338 | font-size: 1.5rem; 339 | } 340 | 341 | .modal-close { 342 | font-size: 1.5rem; 343 | cursor: pointer; 344 | } 345 | 346 | #modal table { 347 | width: 100%; 348 | table-layout: auto; 349 | margin-bottom: 20px; 350 | border-collapse: collapse; 351 | } 352 | 353 | #modal table th { 354 | text-align: left; 355 | border-bottom: 1px solid #555; 356 | } 357 | 358 | #modal table td { 359 | overflow: hidden; 360 | white-space: nowrap; 361 | text-overflow: ellipsis; 362 | max-width: 200px; 363 | } 364 | 365 | #modal button { 366 | padding: 5px 10px; 367 | background-color: #555; 368 | color: #fff; 369 | border: none; 370 | border-radius: 5px; 371 | cursor: pointer; 372 | } 373 | 374 | #modal input { 375 | padding: 5px; 376 | width: 100%; 377 | border-radius: 5px; 378 | background-color: #444; 379 | outline: none; 380 | border: none; 381 | color: #fff; 382 | } 383 | 384 | #modal select { 385 | padding: 8px !important; 386 | font-size: inherit; 387 | width: 100%; 388 | border-radius: 5px; 389 | background-color: #444; 390 | outline: none; 391 | border: none; 392 | color: #fff; 393 | } 394 | 395 | .table-actions { 396 | white-space: nowrap; 397 | padding: 3px 0; 398 | display: flex; 399 | justify-content: right; 400 | } 401 | 402 | .table-actions button { 403 | margin-left: 5px; 404 | } 405 | 406 | .add-button { 407 | margin-top: 10px; 408 | } 409 | 410 | #modal .action-buttons { 411 | margin-top: 20px; 412 | } 413 | 414 | @keyframes fadeIn { 415 | 0% { 416 | opacity: 0; 417 | } 418 | 100% { 419 | opacity: 1; 420 | } 421 | } 422 | 423 | @keyframes fadeOut { 424 | 0% { 425 | opacity: 1; 426 | } 427 | 100% { 428 | opacity: 0; 429 | } 430 | } 431 | 432 | @keyframes zoomIn { 433 | 0% { 434 | transform: scale(0.9); 435 | } 436 | 100% { 437 | transform: scale(1); 438 | } 439 | } 440 | 441 | @keyframes zoomOut { 442 | 0% { 443 | transform: scale(1); 444 | } 445 | 100% { 446 | transform: scale(0.9); 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.8.1](https://github.com/scottmckendry/mnemstart/compare/v0.8.0...v0.8.1) (2025-04-23) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * group name in docker file ([05930bf](https://github.com/scottmckendry/mnemstart/commit/05930bfd1c2b78f4c3bf85cf8ca567b82665cf5a)) 9 | 10 | ## [0.8.0](https://github.com/scottmckendry/mnemstart/compare/v0.7.4...v0.8.0) (2025-04-23) 11 | 12 | 13 | ### Features 14 | 15 | * **container:** optimise image build ([3643e35](https://github.com/scottmckendry/mnemstart/commit/3643e3581beb834228b6af4e26e782e5be65c74a)) 16 | * image security improvements ([b9a5225](https://github.com/scottmckendry/mnemstart/commit/b9a5225b445e2dacfaf2853a6a51d973ee9180ef)) 17 | * use structured logging where practical ([839f3b1](https://github.com/scottmckendry/mnemstart/commit/839f3b1c9ec7e6e5e682597c264a31f02df092de)) 18 | 19 | ## [0.7.4](https://github.com/scottmckendry/mnemstart/compare/v0.7.3...v0.7.4) (2025-03-17) 20 | 21 | 22 | ### Bug Fixes 23 | 24 | * release dependency updates ([a5c1ce8](https://github.com/scottmckendry/mnemstart/commit/a5c1ce86545c27bdb69a0354485f36cdbfcec1b2)) 25 | 26 | ## [0.7.3](https://github.com/scottmckendry/mnemstart/compare/v0.7.2...v0.7.3) (2025-01-17) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * **auth:** fix securecookie error when session value is too long ([84b5cda](https://github.com/scottmckendry/mnemstart/commit/84b5cda61cb07007cda3b87b40befcca7752c6f1)), closes [#31](https://github.com/scottmckendry/mnemstart/issues/31) 32 | 33 | ## [0.7.2](https://github.com/scottmckendry/mnemstart/compare/v0.7.1...v0.7.2) (2024-09-09) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * **ui:** include font-face css in dom ([aa0f543](https://github.com/scottmckendry/mnemstart/commit/aa0f543a3750491fc07d1ec4a6d66956f65b9693)) 39 | 40 | ## [0.7.1](https://github.com/scottmckendry/mnemstart/compare/v0.7.0...v0.7.1) (2024-08-25) 41 | 42 | 43 | ### Bug Fixes 44 | 45 | * **keymaps:** custom mappings not immediately available on save ([0b28342](https://github.com/scottmckendry/mnemstart/commit/0b2834286348de62aeebca4008a85582a4e9672f)) 46 | 47 | ## [0.7.0](https://github.com/scottmckendry/mnemstart/compare/v0.6.0...v0.7.0) (2024-08-25) 48 | 49 | 50 | ### Features 51 | 52 | * **search:** add search suggestions and selection of engines ([07c2b26](https://github.com/scottmckendry/mnemstart/commit/07c2b2602178e734af1a3c847acf54455c5d6c80)) 53 | * **settings:** add setting for toggling search suggestions ([f526619](https://github.com/scottmckendry/mnemstart/commit/f526619fee7b625bd0ed21b4211a38c0573cf2ba)) 54 | * **ui:** add help popup and shortcut ([87f31ce](https://github.com/scottmckendry/mnemstart/commit/87f31ce8e54b37096ec5404d5f3ecf1481875976)) 55 | * **ui:** style search suggestions ([30c49ed](https://github.com/scottmckendry/mnemstart/commit/30c49ed74790d157724a41a4982349d138dcd910)) 56 | * **ui:** update modal styles and layout ([a4d72d8](https://github.com/scottmckendry/mnemstart/commit/a4d72d802bce2b2f36074a9178d90eae943c766f)) 57 | * **ui:** update settings modal style ([8b7071a](https://github.com/scottmckendry/mnemstart/commit/8b7071af9d81e0437c0dae9e05c0a23f9552cf9f)) 58 | 59 | ## [0.6.0](https://github.com/scottmckendry/mnemstart/compare/v0.5.0...v0.6.0) (2024-08-24) 60 | 61 | 62 | ### Features 63 | 64 | * **auth:** add google & gitlab auth providers ([f30ba9b](https://github.com/scottmckendry/mnemstart/commit/f30ba9bee3260cc314a52eb1aa9a1b28a797f87e)) 65 | * **ui:** update login page look and application icons ([994176a](https://github.com/scottmckendry/mnemstart/commit/994176aa8327ce3ac6726769dee8d3dc5307d29d)) 66 | 67 | ## [0.5.0](https://github.com/scottmckendry/mnemstart/compare/v0.4.1...v0.5.0) (2024-08-24) 68 | 69 | 70 | ### Features 71 | 72 | * **ui:** add QoL default shortcuts for modals ([5b6cec4](https://github.com/scottmckendry/mnemstart/commit/5b6cec4c0e26c2c1ac3b1044af1d49e4dd238a8e)) 73 | 74 | 75 | ### Bug Fixes 76 | 77 | * **settings:** add err check for insert query ([6627e9e](https://github.com/scottmckendry/mnemstart/commit/6627e9e42a10cc4fdd6e073447594e22d00421a9)) 78 | 79 | ## [0.4.1](https://github.com/scottmckendry/mnemstart/compare/v0.4.0...v0.4.1) (2024-08-24) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * **settings:** resolve sql bug resulting in duplicated settings ([e191dd5](https://github.com/scottmckendry/mnemstart/commit/e191dd57db2d3a54cfd9866b50239004e8b8d5ad)) 85 | 86 | ## [0.4.0](https://github.com/scottmckendry/mnemstart/compare/v0.3.0...v0.4.0) (2024-08-24) 87 | 88 | 89 | ### Features 90 | 91 | * **api:** add recoverer middleware to gracefully handle panics ([9e88d8d](https://github.com/scottmckendry/mnemstart/commit/9e88d8dc1e5799a673e94dc94dec57aa00005014)) 92 | * **api:** explicitly set methods for routes ([b6b66c2](https://github.com/scottmckendry/mnemstart/commit/b6b66c2d329af6210c08f6cb38b8ad3743d3437b)) 93 | * **auth:** convert auth service to chi middleware ([86597c5](https://github.com/scottmckendry/mnemstart/commit/86597c5e21641f926e60c7bd8902f15795136fd6)) 94 | * **cd:** add deployment job to pipeline to update sever image ([a7f981b](https://github.com/scottmckendry/mnemstart/commit/a7f981b58057b1164fb0a2d1e38019c55f20eb47)) 95 | * **ci:** publish test results to summary ([e3a00b3](https://github.com/scottmckendry/mnemstart/commit/e3a00b3828a11433af20d9b3fa93f1f6eec90075)) 96 | * **keymaps:** show status for incorrectly set leader key ([99f2f96](https://github.com/scottmckendry/mnemstart/commit/99f2f96a1038f5db1b10d3ae4756590420a92cc7)) 97 | 98 | 99 | ### Bug Fixes 100 | 101 | * **data:** resolve provider merge bug ([b9f38f0](https://github.com/scottmckendry/mnemstart/commit/b9f38f0f2d37457853d21b671ebe78cffd365cb1)) 102 | * **keymaps:** sanitize keymap urls before navigating ([a9a389b](https://github.com/scottmckendry/mnemstart/commit/a9a389bac364f53f6c87157399d9e1ff9eb14ab2)) 103 | 104 | ## [0.3.0](https://github.com/scottmckendry/mnemstart/compare/v0.2.1...v0.3.0) (2024-08-23) 105 | 106 | 107 | ### Features 108 | 109 | * **auth:** add config opt to send port number in callback urls ([9ed14d9](https://github.com/scottmckendry/mnemstart/commit/9ed14d9f640e3ed2fabad28c8b12027996a23065)) 110 | 111 | ## [0.2.1](https://github.com/scottmckendry/mnemstart/compare/v0.2.0...v0.2.1) (2024-08-23) 112 | 113 | 114 | ### Bug Fixes 115 | 116 | * **auth:** rovert sessions package to version supported by goth ([b76f922](https://github.com/scottmckendry/mnemstart/commit/b76f92267a0324e7e564064fec22fa795bb26b94)) 117 | * **ci:** include latest tag for published packages ([717f455](https://github.com/scottmckendry/mnemstart/commit/717f455c68ab98d28f2fe63b1196341f8b0fa476)) 118 | 119 | ## [0.2.0](https://github.com/scottmckendry/mnemstart/compare/v0.1.3...v0.2.0) (2024-08-23) 120 | 121 | 122 | ### Features 123 | 124 | * **ci:** add dockerfile ([3521d47](https://github.com/scottmckendry/mnemstart/commit/3521d47907dab5cbaca198c4d16ee3a165ccad7d)) 125 | 126 | ## [0.1.3](https://github.com/scottmckendry/mnemstart/compare/v0.1.2...v0.1.3) (2024-08-22) 127 | 128 | 129 | ### Bug Fixes 130 | 131 | * **ci:** add release please output tag name for image tag ([12c4a9c](https://github.com/scottmckendry/mnemstart/commit/12c4a9cb8e7ca4522f904c11b8c4ccd14c1e143e)) 132 | 133 | ## [0.1.2](https://github.com/scottmckendry/mnemstart/compare/v0.1.1...v0.1.2) (2024-08-22) 134 | 135 | 136 | ### Bug Fixes 137 | 138 | * **ci:** add missing env vars ([b08d800](https://github.com/scottmckendry/mnemstart/commit/b08d800fe1cdf619ced0f342127a5a2ed725eb0f)) 139 | 140 | ## [0.1.1](https://github.com/scottmckendry/mnemstart/compare/v0.1.0...v0.1.1) (2024-08-22) 141 | 142 | 143 | ### Bug Fixes 144 | 145 | * **ci:** check for create release rather than tag ref ([8c3e7c2](https://github.com/scottmckendry/mnemstart/commit/8c3e7c281c6ceac91acb9c2ec6fceac37c4ae75f)) 146 | 147 | ## 0.1.0 (2024-08-22) 148 | 149 | 150 | ### Features 151 | 152 | * add date & time, tidy up styling ([d55adce](https://github.com/scottmckendry/mnemstart/commit/d55adce33ef56d79ca3d9038e88176b0a273f10d)) 153 | * add status line ([9a4611e](https://github.com/scottmckendry/mnemstart/commit/9a4611ecf45e67468010dfee28a8f3440db38d76)) 154 | * **auth:** add discord authentication provider ([adbe180](https://github.com/scottmckendry/mnemstart/commit/adbe180d60c61a48d41edac2f5aa530506a52f96)) 155 | * **auth:** update stored user during oauth2 callback ([4d5d7f2](https://github.com/scottmckendry/mnemstart/commit/4d5d7f2ce59983004044d24cd153e7969bafab96)) 156 | * **ci:** add ci workflow file ([3fbf4c7](https://github.com/scottmckendry/mnemstart/commit/3fbf4c71770af5d3c2b6afd74913866a9faf25c6)) 157 | * **ci:** enable dependabot ([56a7410](https://github.com/scottmckendry/mnemstart/commit/56a74106740f881f69b852e047a5b2217c86d744)) 158 | * **data:** store authenticated users in db ([8027db0](https://github.com/scottmckendry/mnemstart/commit/8027db03195fc2ed5397e5ba4021a46f57ceb213)) 159 | * **keymaps:** add all the CRUD stuff for mappings ([777b9ac](https://github.com/scottmckendry/mnemstart/commit/777b9ac90c0d923678d8ad0fa013a39a2ea594bd)) 160 | * **keymaps:** introduce 'leader mode' for more robust mappings ([19faab5](https://github.com/scottmckendry/mnemstart/commit/19faab5d05804d0fa715dc003601708a6a3ee2f0)) 161 | * **keymaps:** PoC for key event driven shortcuts with client-side JS ([b365d7b](https://github.com/scottmckendry/mnemstart/commit/b365d7b567a797d1f92a73a62d23434936b42a26)) 162 | * minimal icon navigation ([ca46608](https://github.com/scottmckendry/mnemstart/commit/ca4660876a8d35cb4d3097fb278a5d4ec29f1b7f)) 163 | * **settings:** add basic user settings schema ([2b871a2](https://github.com/scottmckendry/mnemstart/commit/2b871a2af455b9233c4c646a71058ee7e3ab774c)) 164 | * **settings:** add settings edit modal and backend logic ([6e1a77b](https://github.com/scottmckendry/mnemstart/commit/6e1a77bb474b38d851fad5982babb59e710bd293)) 165 | * **settings:** render HTML with all current user settings present ([08b2c14](https://github.com/scottmckendry/mnemstart/commit/08b2c141193c98f3978dfe13985465e068d31d4c)) 166 | * tidy up modal styling ([54db3d0](https://github.com/scottmckendry/mnemstart/commit/54db3d0a4e5863809e8f1d4aa45195270fdd364a)) 167 | 168 | 169 | ### Bug Fixes 170 | 171 | * **db:** update user name field conditionally ([4d7761d](https://github.com/scottmckendry/mnemstart/commit/4d7761d70c4359112960078fa9cafb512f642cf9)) 172 | * **log:** remove redundant logs ([c7bea13](https://github.com/scottmckendry/mnemstart/commit/c7bea13b4e292f7944d7dca08905a4e913a7cbe5)) 173 | -------------------------------------------------------------------------------- /views/mappings_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package views 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | import ( 12 | "github.com/markbates/goth" 13 | "github.com/scottmckendry/mnemstart/data" 14 | "strconv" 15 | ) 16 | 17 | func Mappings(user goth.User, mappings []data.Mapping) templ.Component { 18 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 19 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 20 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 21 | return templ_7745c5c3_CtxErr 22 | } 23 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 24 | if !templ_7745c5c3_IsBuffer { 25 | defer func() { 26 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 27 | if templ_7745c5c3_Err == nil { 28 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 29 | } 30 | }() 31 | } 32 | ctx = templ.InitializeContext(ctx) 33 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 34 | if templ_7745c5c3_Var1 == nil { 35 | templ_7745c5c3_Var1 = templ.NopComponent 36 | } 37 | ctx = templ.ClearChildren(ctx) 38 | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 39 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 40 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 41 | if !templ_7745c5c3_IsBuffer { 42 | defer func() { 43 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 44 | if templ_7745c5c3_Err == nil { 45 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 46 | } 47 | }() 48 | } 49 | ctx = templ.InitializeContext(ctx) 50 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 51 | if templ_7745c5c3_Err != nil { 52 | return templ_7745c5c3_Err 53 | } 54 | for _, mapping := range mappings { 55 | templ_7745c5c3_Err = MappingRow(&mapping).Render(ctx, templ_7745c5c3_Buffer) 56 | if templ_7745c5c3_Err != nil { 57 | return templ_7745c5c3_Err 58 | } 59 | } 60 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
KeymapURL
") 61 | if templ_7745c5c3_Err != nil { 62 | return templ_7745c5c3_Err 63 | } 64 | return nil 65 | }) 66 | templ_7745c5c3_Err = Modal("Edit key mappings", true).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 67 | if templ_7745c5c3_Err != nil { 68 | return templ_7745c5c3_Err 69 | } 70 | return nil 71 | }) 72 | } 73 | 74 | func MappingRow(mapping *data.Mapping) templ.Component { 75 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 76 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 77 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 78 | return templ_7745c5c3_CtxErr 79 | } 80 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 81 | if !templ_7745c5c3_IsBuffer { 82 | defer func() { 83 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 84 | if templ_7745c5c3_Err == nil { 85 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 86 | } 87 | }() 88 | } 89 | ctx = templ.InitializeContext(ctx) 90 | templ_7745c5c3_Var3 := templ.GetChildren(ctx) 91 | if templ_7745c5c3_Var3 == nil { 92 | templ_7745c5c3_Var3 = templ.NopComponent 93 | } 94 | ctx = templ.ClearChildren(ctx) 95 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") 96 | if templ_7745c5c3_Err != nil { 97 | return templ_7745c5c3_Err 98 | } 99 | var templ_7745c5c3_Var4 string 100 | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(mapping.Keymap) 101 | if templ_7745c5c3_Err != nil { 102 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/mappings.templ`, Line: 37, Col: 22} 103 | } 104 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 105 | if templ_7745c5c3_Err != nil { 106 | return templ_7745c5c3_Err 107 | } 108 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") 109 | if templ_7745c5c3_Err != nil { 110 | return templ_7745c5c3_Err 111 | } 112 | var templ_7745c5c3_Var5 string 113 | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(mapping.MapsTo) 114 | if templ_7745c5c3_Err != nil { 115 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/mappings.templ`, Line: 38, Col: 22} 116 | } 117 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 118 | if templ_7745c5c3_Err != nil { 119 | return templ_7745c5c3_Err 120 | } 121 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") 148 | if templ_7745c5c3_Err != nil { 149 | return templ_7745c5c3_Err 150 | } 151 | return nil 152 | }) 153 | } 154 | 155 | func NewMapping() templ.Component { 156 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 157 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 158 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 159 | return templ_7745c5c3_CtxErr 160 | } 161 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 162 | if !templ_7745c5c3_IsBuffer { 163 | defer func() { 164 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 165 | if templ_7745c5c3_Err == nil { 166 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 167 | } 168 | }() 169 | } 170 | ctx = templ.InitializeContext(ctx) 171 | templ_7745c5c3_Var8 := templ.GetChildren(ctx) 172 | if templ_7745c5c3_Var8 == nil { 173 | templ_7745c5c3_Var8 = templ.NopComponent 174 | } 175 | ctx = templ.ClearChildren(ctx) 176 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") 177 | if templ_7745c5c3_Err != nil { 178 | return templ_7745c5c3_Err 179 | } 180 | return nil 181 | }) 182 | } 183 | 184 | func EditMapping(user goth.User, mapping *data.Mapping) templ.Component { 185 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 186 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 187 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 188 | return templ_7745c5c3_CtxErr 189 | } 190 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 191 | if !templ_7745c5c3_IsBuffer { 192 | defer func() { 193 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 194 | if templ_7745c5c3_Err == nil { 195 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 196 | } 197 | }() 198 | } 199 | ctx = templ.InitializeContext(ctx) 200 | templ_7745c5c3_Var9 := templ.GetChildren(ctx) 201 | if templ_7745c5c3_Var9 == nil { 202 | templ_7745c5c3_Var9 = templ.NopComponent 203 | } 204 | ctx = templ.ClearChildren(ctx) 205 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ") 271 | if templ_7745c5c3_Err != nil { 272 | return templ_7745c5c3_Err 273 | } 274 | return nil 275 | }) 276 | } 277 | 278 | var _ = templruntime.GeneratedTemplate 279 | --------------------------------------------------------------------------------