├── .gitignore ├── pb_data ├── data.db ├── auxiliary.db └── storage │ └── _pb_users_auth_ │ └── s9du2le16jk23ju │ ├── acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png │ ├── acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png.attrs │ └── thumbs_acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png │ ├── 100x100_acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png.attrs │ └── 100x100_acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png ├── static ├── .DS_Store ├── assets │ ├── favicon.ico │ ├── favicon-96x96.png │ ├── apple-touch-icon.png │ ├── peng-background.webp │ ├── web-app-manifest-192x192.png │ ├── web-app-manifest-512x512.png │ └── site.webmanifest └── css │ └── input.css ├── views ├── components │ ├── RefreshOnContent.templ │ ├── RefreshOnContent_templ.go │ ├── Alert.templ │ ├── Background.templ │ ├── ChatBubble.templ │ ├── Background_templ.go │ ├── Alert_templ.go │ ├── Logos.templ │ ├── ChatBubble_templ.go │ ├── Icons.templ │ └── Logos_templ.go ├── layouts │ ├── MainLayout.templ │ └── MainLayout_templ.go └── pages │ ├── AuthRedirect.templ │ ├── LoginPage.templ │ ├── AuthRedirect_templ.go │ ├── LoginPage_templ.go │ └── HomePage.templ ├── cmd ├── db-server │ └── main.go └── t3-clone │ └── main.go ├── Makefile ├── handlers ├── hotreload.go ├── pinghub.go ├── ssehub.go ├── home.go ├── static.go ├── login.go ├── auth.go └── chat.go ├── db ├── db.go ├── authMethods.go ├── models.go └── auth.go ├── LICENSE ├── services ├── ssehub.go ├── sessions.go └── openrouter.go ├── go.mod ├── README.md ├── utils └── loggger.go └── pb_schema.json /.gitignore: -------------------------------------------------------------------------------- 1 | go.sum 2 | .env 3 | 4 | tailwindcss 5 | pocketbase 6 | -------------------------------------------------------------------------------- /pb_data/data.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/pb_data/data.db -------------------------------------------------------------------------------- /static/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/static/.DS_Store -------------------------------------------------------------------------------- /pb_data/auxiliary.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/pb_data/auxiliary.db -------------------------------------------------------------------------------- /static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/static/assets/favicon.ico -------------------------------------------------------------------------------- /static/assets/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/static/assets/favicon-96x96.png -------------------------------------------------------------------------------- /static/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/static/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /static/assets/peng-background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/static/assets/peng-background.webp -------------------------------------------------------------------------------- /static/assets/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/static/assets/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /static/assets/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/static/assets/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /views/components/RefreshOnContent.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ RefreshOnContent() { 4 |
7 |
8 | } 9 | -------------------------------------------------------------------------------- /cmd/db-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/pocketbase/pocketbase" 7 | ) 8 | 9 | 10 | func main() { 11 | app := pocketbase.New() 12 | 13 | 14 | err := app.Start() 15 | if err != nil { 16 | log.Fatal(err) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | run: 5 | go tool templ generate -cmd="go run ./cmd/t3-clone" -watch 6 | 7 | css: 8 | ./tailwindcss -i static/css/input.css -o static/css/styles.css --minify --watch 9 | 10 | 11 | db: 12 | go run ./cmd/db-server/main.go serve 13 | 14 | 15 | 16 | .PHONY: run, css, db 17 | 18 | -------------------------------------------------------------------------------- /pb_data/storage/_pb_users_auth_/s9du2le16jk23ju/acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/pb_data/storage/_pb_users_auth_/s9du2le16jk23ju/acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png -------------------------------------------------------------------------------- /pb_data/storage/_pb_users_auth_/s9du2le16jk23ju/acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png.attrs: -------------------------------------------------------------------------------- 1 | {"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":{"original-filename":"acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87.png"},"md5":"HJNaQZW321YFVAF45s4ysA=="} 2 | -------------------------------------------------------------------------------- /pb_data/storage/_pb_users_auth_/s9du2le16jk23ju/thumbs_acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png/100x100_acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png.attrs: -------------------------------------------------------------------------------- 1 | {"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null,"md5":"RYgsvy88PppfKanmsVm6zA=="} 2 | -------------------------------------------------------------------------------- /handlers/hotreload.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | "time" 7 | 8 | datastar "github.com/starfederation/datastar/sdk/go" 9 | ) 10 | 11 | var once sync.Once 12 | 13 | func SSEHotreload(w http.ResponseWriter, r *http.Request) { 14 | sse := datastar.NewSSE(w, r) 15 | 16 | once.Do(func() { 17 | sse.ExecuteScript( 18 | "window.location.reload()", 19 | datastar.WithExecuteScriptRetryDuration(time.Second)) 20 | }) 21 | 22 | <- r.Context().Done() 23 | } 24 | -------------------------------------------------------------------------------- /pb_data/storage/_pb_users_auth_/s9du2le16jk23ju/thumbs_acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png/100x100_acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morethancoder/t3-clone/main/pb_data/storage/_pb_users_auth_/s9du2le16jk23ju/thumbs_acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png/100x100_acg8oc_lkpm_we_szj_xp_q_o4_yi_qz_p8_v9_ak2sjcz5_tjn_b0dhg0_aj7_p_tcd_z2_s96_c_02l1wdhu87_whhbcky808.png -------------------------------------------------------------------------------- /static/assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Gunter AI Chat", 3 | "short_name": "Gunter", 4 | "icons": [ 5 | { 6 | "src": "/static/assets/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/static/assets/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#fdd835", 19 | "background_color": "#09080a", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /handlers/pinghub.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "morethancoder/t3-clone/services" 5 | "morethancoder/t3-clone/utils" 6 | "morethancoder/t3-clone/views/components" 7 | "net/http" 8 | ) 9 | 10 | func POSTPingHub(w http.ResponseWriter, r *http.Request) { 11 | uid := r.URL.Query().Get("uid") 12 | if uid == "" { 13 | utils.Log.Error("No uid found") 14 | http.Error(w, "No uid found", http.StatusBadRequest) 15 | return 16 | } 17 | 18 | // alert user 19 | services.UserSSEHub.BroadcastFragments(uid, 20 | components.Alert(components.AlertData{ 21 | Level: "info", 22 | Message: "Pong", 23 | })) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "morethancoder/t3-clone/utils" 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | type db struct { 12 | Url string 13 | Client *http.Client 14 | } 15 | 16 | var Db *db 17 | 18 | func init() { 19 | var err error 20 | Db, err = NewDB() 21 | if err != nil { 22 | utils.Log.Fatal(err.Error()) 23 | } 24 | } 25 | 26 | func NewDB() (*db, error) { 27 | if os.Getenv("DB_URL") == "" { 28 | return nil, errors.New("[ERROR] env DB_URL is not set") 29 | } 30 | return &db{ 31 | Url: os.Getenv("DB_URL"), 32 | Client: &http.Client{}, 33 | }, nil 34 | } 35 | 36 | func FileUrl(collectionId, recordId, filename string) string { 37 | return fmt.Sprintf("%s/api/files/%s/%s/%s?token=", Db.Url, collectionId ,recordId ,filename) 38 | } 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /views/layouts/MainLayout.templ: -------------------------------------------------------------------------------- 1 | package layouts 2 | 3 | import "morethancoder/t3-clone/views/components" 4 | 5 | templ MainLayout(view templ.Component) { 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Gunter 18 | 19 | 20 | 21 | 22 | @view 23 | @components.Alert(components.AlertData{}) 24 | 25 | 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 morethancoder 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/pages/AuthRedirect.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import "morethancoder/t3-clone/views/components" 4 | 5 | templ AuthRedirect(title string) { 6 |
7 | 8 |
9 |
11 |
12 |
13 |
14 |
15 | 16 | @components.IconLoading("size-6") 17 | 18 |
19 |
20 |

{title}

21 |

Please wait a moment to be redirected

22 |
23 |
24 |
25 | 26 | @components.BackgroundBlobs() 27 |
28 | } 29 | -------------------------------------------------------------------------------- /cmd/t3-clone/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "morethancoder/t3-clone/handlers" 5 | "morethancoder/t3-clone/services" 6 | "morethancoder/t3-clone/utils" 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | if os.Getenv("ENV") == "dev" { 13 | utils.Log.DebugMode = true 14 | } 15 | 16 | go services.LoopAndCleanSessionStore() 17 | 18 | mux := http.NewServeMux() 19 | 20 | mux.HandleFunc("GET /sse", handlers.SSEHub) 21 | mux.HandleFunc("GET /chat/new", handlers.GETNewChat) 22 | mux.HandleFunc("POST /chat", handlers.POSTChat) 23 | 24 | mux.HandleFunc("GET /login", handlers.GETLogin()) 25 | mux.HandleFunc("GET /sign-out", handlers.GETSignOut()) 26 | 27 | mux.HandleFunc("POST /auth-redirect", handlers.POSTAuthRedirect()) 28 | mux.HandleFunc("GET /auth-redirect", handlers.GETAuthRedirect()) 29 | 30 | if os.Getenv("ENV") == "dev" { 31 | mux.HandleFunc("GET /hotreload", handlers.SSEHotreload) 32 | } 33 | 34 | mux.Handle("GET /static/{filepath...}", handlers.GETStatic()) 35 | mux.HandleFunc("GET /", handlers.GETHome()) 36 | 37 | utils.Log.Debug("Listening on port 8080") 38 | 39 | http.ListenAndServe(":8080", mux) 40 | } 41 | -------------------------------------------------------------------------------- /handlers/ssehub.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "morethancoder/t3-clone/db" 6 | "morethancoder/t3-clone/services" 7 | "morethancoder/t3-clone/utils" 8 | "morethancoder/t3-clone/views/components" 9 | "net/http" 10 | 11 | datastar "github.com/starfederation/datastar/sdk/go" 12 | ) 13 | 14 | func SSEHub(w http.ResponseWriter, r *http.Request) { 15 | jwtCookie, err := r.Cookie("jwt") 16 | if err != nil { 17 | utils.Log.Error("No jwt cookie found") 18 | http.Redirect(w, r, "/login", http.StatusSeeOther) 19 | return 20 | } 21 | 22 | res, err := db.Db.AuthRefresh(jwtCookie.Value) 23 | if err != nil { 24 | utils.Log.Error(err.Error()) 25 | http.Error(w, "Failed to auth refresh", http.StatusUnauthorized) 26 | return 27 | } 28 | 29 | ctx := context.WithValue(r.Context(), "AuthRecord", res.Record) 30 | r = r.WithContext(ctx) 31 | 32 | uid := res.Record.ID 33 | sse := datastar.NewSSE(w, r) 34 | services.UserSSEHub.Add(uid, sse) 35 | defer services.UserSSEHub.Remove(uid, sse) 36 | 37 | utils.Log.Debug("ssehub %+v", services.UserSSEHub) 38 | 39 | //send connected alert 40 | services.UserSSEHub.BroadcastFragments(uid, 41 | components.Alert(components.AlertData{ 42 | Level: "success", 43 | Message: "Connected", 44 | })) 45 | 46 | <-r.Context().Done() 47 | } 48 | -------------------------------------------------------------------------------- /handlers/home.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "morethancoder/t3-clone/db" 6 | "morethancoder/t3-clone/utils" 7 | "morethancoder/t3-clone/views/layouts" 8 | "morethancoder/t3-clone/views/pages" 9 | "net/http" 10 | ) 11 | 12 | func GETHome() http.HandlerFunc { 13 | return func(w http.ResponseWriter, r *http.Request) { 14 | jwtCookie, err := r.Cookie("jwt") 15 | if err != nil { 16 | utils.Log.Error("No jwt cookie found") 17 | http.Redirect(w, r, "/login", http.StatusSeeOther) 18 | return 19 | } 20 | 21 | res, err := db.Db.AuthRefresh(jwtCookie.Value) 22 | if err != nil { 23 | utils.Log.Error(err.Error()) 24 | http.SetCookie(w, &http.Cookie{ 25 | Name: "jwt", 26 | MaxAge: -1, 27 | HttpOnly: true, 28 | }) 29 | http.Error(w, "Failed to auth refresh", http.StatusUnauthorized) 30 | return 31 | } 32 | 33 | getModelsRes, err := db.Db.GetModelRecords(map[db.QueryParam]string{}) 34 | if err != nil { 35 | utils.Log.Error(err.Error()) 36 | http.Error(w, "Failed to get models", http.StatusInternalServerError) 37 | return 38 | } 39 | 40 | mappedModels := db.GroupModelRecordsByCompany(getModelsRes.Items) 41 | ctx := context.WithValue(r.Context(), "Models", mappedModels) 42 | ctx = context.WithValue(ctx, "AuthRecord", res.Record) 43 | r = r.WithContext(ctx) 44 | 45 | err = layouts.MainLayout(pages.HomePage()).Render(r.Context(), w) 46 | if err != nil { 47 | utils.Log.Error(err.Error()) 48 | http.Error(w, "Something went wrong", http.StatusInternalServerError) 49 | return 50 | } 51 | 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /views/components/RefreshOnContent_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package components 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 RefreshOnContent() 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, "
") 33 | if templ_7745c5c3_Err != nil { 34 | return templ_7745c5c3_Err 35 | } 36 | return nil 37 | }) 38 | } 39 | 40 | var _ = templruntime.GeneratedTemplate 41 | -------------------------------------------------------------------------------- /views/components/Alert.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | type AlertData struct { 4 | Level string 5 | Message string 6 | Icon templ.Component 7 | } 8 | 9 | templ Alert(data AlertData) { 10 | 53 | } 54 | -------------------------------------------------------------------------------- /handlers/static.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "mime" 5 | "net/http" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | func GETStatic() http.Handler { 11 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | // get path and check if it is gzipped 13 | path := r.URL.Path 14 | gzipped := strings.HasSuffix(path, ".gz") 15 | if gzipped { 16 | path = strings.TrimSuffix(path, ".gz") 17 | } 18 | 19 | // get the extension of file 20 | ext := filepath.Ext(path) 21 | 22 | // get content type 23 | contentType := mime.TypeByExtension(ext) 24 | 25 | // default fallbacks 26 | if contentType == "" { 27 | switch ext { 28 | case ".js": 29 | contentType = "application/javascript" 30 | case ".css": 31 | contentType = "text/css" 32 | case ".html": 33 | contentType = "text/html" 34 | default: 35 | contentType = "application/octet-stream" 36 | } 37 | } 38 | 39 | // set content type to let browser know what to expect 40 | w.Header().Set("Content-Type", contentType) 41 | 42 | // handle file compression 43 | if gzipped { 44 | w.Header().Set("Content-Encoding", "gzip") 45 | w.Header().Set("Vary", "Accept-Encoding") 46 | } 47 | 48 | // cache control 1 year 49 | w.Header().Set("Cache-Control", "public, max-age=31536000") 50 | 51 | // strip prefix to allow file server to find file 52 | http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r) 53 | 54 | }) 55 | } 56 | 57 | -------------------------------------------------------------------------------- /services/ssehub.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/a-h/templ" 7 | datastar "github.com/starfederation/datastar/sdk/go" 8 | ) 9 | 10 | type SSEHub struct { 11 | mutex sync.Mutex 12 | // client can have multiple connections 13 | clients map[string][]*datastar.ServerSentEventGenerator 14 | } 15 | 16 | func NewSSEHub() *SSEHub { 17 | return &SSEHub{ 18 | clients: make(map[string][]*datastar.ServerSentEventGenerator), 19 | } 20 | } 21 | 22 | var UserSSEHub = NewSSEHub() 23 | 24 | func (hub *SSEHub) Add(userId string, sse *datastar.ServerSentEventGenerator) { 25 | hub.mutex.Lock() 26 | defer hub.mutex.Unlock() 27 | hub.clients[userId] = append(hub.clients[userId], sse) 28 | } 29 | 30 | func (hub *SSEHub) Remove(userId string, sse *datastar.ServerSentEventGenerator) { 31 | hub.mutex.Lock() 32 | defer hub.mutex.Unlock() 33 | for i, c := range hub.clients[userId] { 34 | if c == sse { 35 | hub.clients[userId] = append(hub.clients[userId][:i], hub.clients[userId][i+1:]...) 36 | break 37 | } 38 | } 39 | 40 | } 41 | 42 | func (hub *SSEHub) BroadcastFragments(userId string, component templ.Component) { 43 | hub.mutex.Lock() 44 | defer hub.mutex.Unlock() 45 | 46 | for _, sse := range hub.clients[userId] { 47 | sse.MergeFragmentTempl(component) 48 | } 49 | 50 | } 51 | 52 | func (hub *SSEHub) BroadcastSignals(userId string, signals []byte) { 53 | hub.mutex.Lock() 54 | defer hub.mutex.Unlock() 55 | 56 | for _, sse := range hub.clients[userId] { 57 | sse.MergeSignals(signals) 58 | } 59 | 60 | } 61 | 62 | func (hub *SSEHub) ExcuteScript(userId string, script string) { 63 | hub.mutex.Lock() 64 | defer hub.mutex.Unlock() 65 | 66 | for _, sse := range hub.clients[userId] { 67 | sse.ExecuteScript(script) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /db/authMethods.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "errors" 8 | ) 9 | 10 | type AuthMethods struct { 11 | MFA MFAConfig `json:"mfa"` 12 | OAuth2 OAuth2Config `json:"oauth2"` 13 | OTP OTPConfig `json:"otp"` 14 | Password PasswordConfig `json:"password"` 15 | } 16 | 17 | type MFAConfig struct { 18 | Duration int `json:"duration"` 19 | Enabled bool `json:"enabled"` 20 | } 21 | 22 | type OTPConfig struct { 23 | Duration int `json:"duration"` 24 | Enabled bool `json:"enabled"` 25 | } 26 | 27 | type PasswordConfig struct { 28 | Enabled bool `json:"enabled"` 29 | IdentityFields []string `json:"identityFields"` 30 | } 31 | 32 | type OAuth2Config struct { 33 | Enabled bool `json:"enabled"` 34 | Providers []OAuth2Provider `json:"providers"` 35 | } 36 | 37 | type OAuth2Provider struct { 38 | AuthURL string `json:"authURL"` 39 | AuthUrl string `json:"authUrl"` 40 | CodeChallenge string `json:"codeChallenge"` 41 | CodeChallengeMethod string `json:"codeChallengeMethod"` 42 | CodeVerifier string `json:"codeVerifier"` 43 | DisplayName string `json:"displayName"` 44 | Name string `json:"name"` 45 | State string `json:"state"` 46 | } 47 | 48 | func (db *db) GetAuthMethods() (*AuthMethods, error) { 49 | authMethodsEndpoint := "/api/collections/users/auth-methods" 50 | 51 | req, err := http.NewRequest("GET", db.Url+authMethodsEndpoint, nil) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | resp, err := db.Client.Do(req) 57 | if err != nil { 58 | return nil, err 59 | } 60 | defer resp.Body.Close() 61 | 62 | respBody, err := io.ReadAll(resp.Body) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | if resp.StatusCode != http.StatusOK { 68 | return nil, errors.New(string(respBody)) 69 | } 70 | 71 | var authMethods AuthMethods 72 | if err := json.Unmarshal(respBody, &authMethods); err != nil { 73 | return nil, err 74 | } 75 | 76 | return &authMethods, nil 77 | } 78 | 79 | -------------------------------------------------------------------------------- /views/pages/LoginPage.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import "morethancoder/t3-clone/views/components" 4 | import "context" 5 | import "morethancoder/t3-clone/db" 6 | import "os" 7 | 8 | func GetOAuth2Providers(ctx context.Context) []db.OAuth2Provider { 9 | data, ok := ctx.Value("OAuth2Providers").([]db.OAuth2Provider) 10 | if !ok { 11 | return []db.OAuth2Provider{} 12 | } else { 13 | return data 14 | } 15 | } 16 | 17 | templ LoginPage() { 18 |
19 | 20 |
21 |
24 |
25 |
26 |
27 |
28 | 29 | @components.IconLockWithHole("size-5") 30 | 31 |
32 |
33 |

Welcome Back

34 |

Sign in to continue to your account

35 | for _,provider := range GetOAuth2Providers(ctx) { 36 | 39 | switch provider.Name { 40 | case "google": 41 | @components.LogoGoogleColored("size-5") 42 | case "github": 43 | @components.LogoGithub("size-5") 44 | 45 | } 46 | Sign in with {provider.DisplayName} 47 | 48 | 49 | } 50 | 51 |
52 |

By continuing, you agree to our Terms of Service

53 |
54 |
55 | @components.BackgroundBlobs() 56 |
57 |
58 | } 59 | 60 | 61 | -------------------------------------------------------------------------------- /static/css/input.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" source(none); 2 | @source "../../views/**/*.templ"; 3 | 4 | @plugin "../js/daisyui.js" { 5 | exclude: rootscrollgutter; 6 | } 7 | 8 | /* omg i found what the issue is you can't use ; after curly brackets */ 9 | @plugin "../js/daisyui-theme.js" { 10 | name: "gunter"; 11 | default: true; 12 | prefersdark: false; 13 | color-scheme: "dark"; 14 | --color-base-100: oklch(14% 0.005 285.823); 15 | --color-base-200: oklch(21% 0.006 285.885); 16 | --color-base-300: oklch(27% 0.006 286.033); 17 | --color-base-content: oklch(96% 0.001 286.375); 18 | --color-primary: oklch(85% 0.199 91.936); 19 | --color-primary-content: oklch(28% 0.066 53.813); 20 | --color-secondary: oklch(86% 0.127 207.078); 21 | --color-secondary-content: oklch(30% 0.056 229.695); 22 | --color-accent: oklch(86% 0.022 252.894); 23 | --color-accent-content: oklch(12% 0.042 264.695); 24 | --color-neutral: oklch(98% 0.002 247.839); 25 | --color-neutral-content: oklch(13% 0.028 261.692); 26 | --color-info: oklch(91% 0.08 205.041); 27 | --color-info-content: oklch(30% 0.056 229.695); 28 | --color-success: oklch(85% 0.138 181.071); 29 | --color-success-content: oklch(30% 0.056 229.695); 30 | --color-warning: oklch(82% 0.189 84.429); 31 | --color-warning-content: oklch(27% 0.077 45.635); 32 | --color-error: oklch(63% 0.237 25.331); 33 | --color-error-content: oklch(25% 0.092 26.042); 34 | --radius-selector: 2rem; 35 | --radius-field: 2rem; 36 | --radius-box: 2rem; 37 | --size-selector: 0.25rem; 38 | --size-field: 0.25rem; 39 | --border: 1px; 40 | --depth: 1; 41 | --noise: 1; 42 | } 43 | 44 | 45 | 46 | @plugin "@tailwindcss/typography"; 47 | 48 | 49 | /* .glow { */ 50 | /* filter: drop-shadow(0 0 15px currentColor); */ 51 | /* } */ 52 | 53 | @utility glow { 54 | filter: drop-shadow(0 0 15px currentColor); 55 | } 56 | 57 | @theme { 58 | @keyframes reflect { 59 | 0% { 60 | transform: translateX(-100%); 61 | opacity: 0; 62 | } 63 | 50% { 64 | transform: translateX(0); 65 | opacity: 1; 66 | } 67 | 100% { 68 | transform: translateX(100%); 69 | opacity: 0; 70 | } 71 | } 72 | } 73 | 74 | @utility animate-reflect { 75 | animation: reflect 0.5s ease-out; 76 | } 77 | -------------------------------------------------------------------------------- /services/sessions.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "morethancoder/t3-clone/utils" 5 | "os" 6 | "sync" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | ) 11 | 12 | const expiryKey = "expiry" 13 | 14 | var SessionStore sync.Map 15 | 16 | type Session struct { 17 | ID string 18 | Data map[string]interface{} 19 | Expiry time.Time 20 | } 21 | 22 | func NewSession(data map[string]interface{}) *Session { 23 | session := &Session{ 24 | ID: uuid.NewString(), 25 | Data: data, 26 | } 27 | if session.Expiry.IsZero() { 28 | if os.Getenv("ENV") == "dev" { 29 | session.Expiry = time.Now().Add(time.Minute * 10) 30 | } else { 31 | session.Expiry = time.Now().Add(time.Hour * 24) 32 | } 33 | } 34 | session.Save() 35 | return session 36 | } 37 | 38 | func (s *Session) Save() { 39 | s.Data[expiryKey] = s.Expiry 40 | SessionStore.Store(s.ID, s.Data) 41 | utils.Log.Debug("Saved session %s", s.ID) 42 | } 43 | 44 | func (s *Session) Load() bool { 45 | value, ok := SessionStore.Load(s.ID) 46 | if ok { 47 | data := value.(map[string]interface{}) 48 | s.Data = data 49 | s.Expiry = data[expiryKey].(time.Time) 50 | } 51 | utils.Log.Debug("Loaded session %s", s.ID) 52 | return ok 53 | } 54 | 55 | func hasSessions() bool { 56 | has := false 57 | SessionStore.Range(func(key, value any) bool { 58 | has = true 59 | return false 60 | }) 61 | utils.Log.Debug("Has sessions: %v", has) 62 | return has 63 | } 64 | 65 | func LoopAndCleanSessionStore() { 66 | // ctx, _ := context.WithCancel(context.Background()) 67 | for { 68 | // select { 69 | // case <- ctx.Done(): 70 | // utils.Log.Debug("LoopAndCleanSessionStore stopped") 71 | // return 72 | // default: 73 | // } 74 | 75 | if os.Getenv("ENV") == "dev" { 76 | time.Sleep(time.Minute * 1) 77 | } else { 78 | time.Sleep(time.Hour * 1) 79 | } 80 | 81 | if !hasSessions() { 82 | continue 83 | } 84 | 85 | now := time.Now() 86 | utils.Log.Debug("Cleaning session store %v", now.Format(time.RFC3339)) 87 | SessionStore.Range(func(key, value any) bool { 88 | expiry, ok := value.(map[string]interface{})[expiryKey].(time.Time) 89 | if ok { 90 | if expiry.Before(now) { 91 | SessionStore.Delete(key) 92 | utils.Log.Debug("Deleted session %s", key) 93 | } 94 | } 95 | return true 96 | }) 97 | utils.Log.Debug( 98 | "Cleaned session store %v (%v)", 99 | time.Now().Format(time.RFC3339), 100 | time.Duration(time.Now().UnixNano()-now.UnixNano())) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module morethancoder/t3-clone 2 | 3 | go 1.24.0 4 | 5 | tool github.com/a-h/templ/cmd/templ 6 | 7 | require ( 8 | github.com/a-h/templ v0.3.898 9 | github.com/pocketbase/pocketbase v0.28.3 10 | github.com/starfederation/datastar v0.21.4 11 | ) 12 | 13 | require ( 14 | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect 15 | github.com/andybalholm/brotli v1.1.1 // indirect 16 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 17 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 18 | github.com/cli/browser v1.3.0 // indirect 19 | github.com/delaneyj/gostar v0.8.0 // indirect 20 | github.com/disintegration/imaging v1.6.2 // indirect 21 | github.com/domodwyer/mailyak/v3 v3.6.2 // indirect 22 | github.com/dustin/go-humanize v1.0.1 // indirect 23 | github.com/fatih/color v1.18.0 // indirect 24 | github.com/fsnotify/fsnotify v1.7.0 // indirect 25 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 26 | github.com/ganigeorgiev/fexpr v0.5.0 // indirect 27 | github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect 28 | github.com/goccy/go-json v0.10.4 // indirect 29 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 30 | github.com/google/uuid v1.6.0 // indirect 31 | github.com/igrmk/treemap/v2 v2.0.1 // indirect 32 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 33 | github.com/mattn/go-colorable v0.1.14 // indirect 34 | github.com/mattn/go-isatty v0.0.20 // indirect 35 | github.com/natefinch/atomic v1.0.1 // indirect 36 | github.com/ncruces/go-strftime v0.1.9 // indirect 37 | github.com/pocketbase/dbx v1.11.0 // indirect 38 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 39 | github.com/samber/lo v1.47.0 // indirect 40 | github.com/spf13/cast v1.9.2 // indirect 41 | github.com/spf13/cobra v1.9.1 // indirect 42 | github.com/spf13/pflag v1.0.6 // indirect 43 | github.com/valyala/bytebufferpool v1.0.0 // indirect 44 | github.com/yuin/goldmark v1.7.12 // indirect 45 | golang.org/x/crypto v0.39.0 // indirect 46 | golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect 47 | golang.org/x/image v0.28.0 // indirect 48 | golang.org/x/mod v0.25.0 // indirect 49 | golang.org/x/net v0.41.0 // indirect 50 | golang.org/x/oauth2 v0.30.0 // indirect 51 | golang.org/x/sync v0.15.0 // indirect 52 | golang.org/x/sys v0.33.0 // indirect 53 | golang.org/x/text v0.26.0 // indirect 54 | golang.org/x/tools v0.34.0 // indirect 55 | modernc.org/libc v1.65.10 // indirect 56 | modernc.org/mathutil v1.7.1 // indirect 57 | modernc.org/memory v1.11.0 // indirect 58 | modernc.org/sqlite v1.38.0 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /handlers/login.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "morethancoder/t3-clone/db" 6 | "morethancoder/t3-clone/services" 7 | "morethancoder/t3-clone/utils" 8 | "morethancoder/t3-clone/views/layouts" 9 | "morethancoder/t3-clone/views/pages" 10 | "net/http" 11 | "os" 12 | ) 13 | 14 | const KeyOAuth2Providers = "OAuth2Providers" 15 | const KeySessionID = "SessionID" 16 | 17 | func GETLogin() http.HandlerFunc { 18 | return func(w http.ResponseWriter, r *http.Request) { 19 | _, err := r.Cookie("jwt") 20 | if err == nil { 21 | utils.Log.Info("Already authenticated" ) 22 | http.Redirect(w, r, "/", http.StatusSeeOther) 23 | return 24 | } 25 | 26 | utils.Log.Debug(err.Error()) 27 | 28 | // no jwt cookie found 29 | // fetching session cookie 30 | cookie, err := r.Cookie(KeySessionID) 31 | if err == nil { 32 | // cookie found user has session stored 33 | session := services.Session{ 34 | ID: cookie.Value, 35 | } 36 | 37 | loaded := session.Load() 38 | if loaded { 39 | authProviders, ok := session.Data[KeyOAuth2Providers] 40 | if ok { 41 | ctx := context.WithValue(r.Context(), KeyOAuth2Providers, authProviders) 42 | r = r.WithContext(ctx) 43 | 44 | err = layouts.MainLayout(pages.LoginPage()).Render(r.Context(), w) 45 | if err != nil { 46 | utils.Log.Error(err.Error()) 47 | http.Error(w, "Something went wrong", http.StatusInternalServerError) 48 | return 49 | } 50 | return 51 | } 52 | } 53 | } 54 | 55 | // fetch available auth methods 56 | authMethods, err := db.Db.GetAuthMethods() 57 | if err != nil { 58 | utils.Log.Error(err.Error()) 59 | http.Error(w, "Failed to get auth methods", http.StatusInternalServerError) 60 | return 61 | } 62 | 63 | // create session 64 | session := services.NewSession(map[string]interface{}{ 65 | KeyOAuth2Providers: authMethods.OAuth2.Providers, 66 | }) 67 | 68 | // send session id to client as cookie 69 | cookie = &http.Cookie{ 70 | Name: KeySessionID, 71 | Value: session.ID, 72 | Expires: session.Expiry, 73 | Path: "/", 74 | HttpOnly: true, 75 | SameSite: http.SameSiteLaxMode, 76 | } 77 | 78 | if os.Getenv("ENV") != "dev" { 79 | cookie.Secure = true 80 | } 81 | 82 | http.SetCookie(w,cookie) 83 | 84 | // update context to update front-end 85 | ctx := context.WithValue(r.Context(), KeyOAuth2Providers, authMethods.OAuth2.Providers) 86 | r = r.WithContext(ctx) 87 | 88 | err = layouts.MainLayout(pages.LoginPage()).Render(r.Context(), w) 89 | if err != nil { 90 | utils.Log.Error(err.Error()) 91 | http.Error(w, "Something went wrong", http.StatusInternalServerError) 92 | return 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /db/models.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | ) 11 | 12 | type ModelRecord struct { 13 | CollectionID string `json:"collectionId"` 14 | CollectionName string `json:"collectionName"` 15 | ID string `json:"id"` 16 | Name string `json:"name"` 17 | Created string `json:"created"` 18 | Updated string `json:"updated"` 19 | } 20 | 21 | type ModelRecordsResponse struct { 22 | Page int `json:"page"` 23 | PerPage int `json:"perPage"` 24 | TotalPages int `json:"totalPages"` 25 | TotalItems int `json:"totalItems"` 26 | Items []ModelRecord `json:"items"` 27 | } 28 | 29 | 30 | const ( 31 | QueryParamPage QueryParam = "page" 32 | QueryParamPerPage QueryParam = "perPage" 33 | QueryParamSort QueryParam = "sort" 34 | QueryParamFilter QueryParam = "filter" 35 | QueryParamSkipTotal QueryParam = "skipTotal" 36 | ) 37 | 38 | func (db *db) GetModelRecords(queryParams map[QueryParam]string) (*ModelRecordsResponse, error) { 39 | recordsEndpoint := "/api/collections/models/records" 40 | 41 | queryString := "" 42 | if len(queryParams) > 0 { 43 | query := url.Values{} 44 | for key, value := range queryParams { 45 | query.Add(string(key), value) 46 | } 47 | queryString = "?" + query.Encode() 48 | } 49 | 50 | req, err := http.NewRequest("GET", db.Url+recordsEndpoint+queryString, nil) 51 | if err != nil { 52 | return nil, fmt.Errorf("[ERROR] failed to create request: %w", err) 53 | } 54 | 55 | req.Header.Set("Content-Type", "application/json") 56 | 57 | resp, err := db.Client.Do(req) 58 | if err != nil { 59 | return nil, fmt.Errorf("[ERROR] failed to make request: %w", err) 60 | } 61 | defer resp.Body.Close() 62 | 63 | respBody, err := io.ReadAll(resp.Body) 64 | if err != nil { 65 | return nil, fmt.Errorf("[ERROR] failed to read response body: %w", err) 66 | } 67 | 68 | if resp.StatusCode != http.StatusOK { 69 | return nil, fmt.Errorf("[ERROR] request failed with status %d: %s", resp.StatusCode, string(respBody)) 70 | } 71 | 72 | var recordsResponse ModelRecordsResponse 73 | if err := json.Unmarshal(respBody, &recordsResponse); err != nil { 74 | return nil, fmt.Errorf("[ERROR] failed to parse response: %w", err) 75 | } 76 | 77 | return &recordsResponse, nil 78 | } 79 | 80 | func GroupModelRecordsByCompany(records []ModelRecord) map[string][]ModelRecord { 81 | groupedModels := make(map[string][]ModelRecord) 82 | 83 | for _, record := range records { 84 | parts := strings.Split(record.Name, "/") 85 | if len(parts) != 2 { 86 | continue 87 | } 88 | company := parts[0] 89 | groupedModels[company] = append(groupedModels[company], record) 90 | } 91 | 92 | return groupedModels 93 | } 94 | -------------------------------------------------------------------------------- /views/layouts/MainLayout_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package layouts 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 "morethancoder/t3-clone/views/components" 12 | 13 | func MainLayout(view templ.Component) 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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Gunter") 35 | if templ_7745c5c3_Err != nil { 36 | return templ_7745c5c3_Err 37 | } 38 | templ_7745c5c3_Err = view.Render(ctx, templ_7745c5c3_Buffer) 39 | if templ_7745c5c3_Err != nil { 40 | return templ_7745c5c3_Err 41 | } 42 | templ_7745c5c3_Err = components.Alert(components.AlertData{}).Render(ctx, templ_7745c5c3_Buffer) 43 | if templ_7745c5c3_Err != nil { 44 | return templ_7745c5c3_Err 45 | } 46 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") 47 | if templ_7745c5c3_Err != nil { 48 | return templ_7745c5c3_Err 49 | } 50 | return nil 51 | }) 52 | } 53 | 54 | var _ = templruntime.GeneratedTemplate 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gunter 2 | 3 | A simple project setup using PocketBase and Go. Follow the steps below to get the project up and running. 4 | 5 | --- 6 | 7 | ## Getting Started 8 | 9 | ### Prerequisites 10 | - [Go](https://go.dev/) installed on your system 11 | - [PocketBase](https://pocketbase.io/) installed and running 12 | - Google OAuth provider details (Client ID and Client Secret) 13 | 14 | --- 15 | 16 | ### Installation 17 | 18 | 1. **Clone the Repository** 19 | Clone the project repository to your local machine: 20 | ```bash 21 | git clone 22 | cd gunter 23 | ``` 24 | 25 | 2. **Install Dependencies** 26 | Run the following command to install all required dependencies: 27 | ```bash 28 | go mod tidy 29 | ``` 30 | 31 | 3. **Set Up PocketBase Schema** 32 | - Open your PocketBase admin panel. 33 | - Load the schema from the `pb_schema.json` file into PocketBase. 34 | (This file contains the necessary collections and fields for the project.) 35 | 36 | 4. **Run PocketBase** 37 | You can start the PocketBase server using the provided `Makefile`. Simply run: 38 | ```bash 39 | make db 40 | ``` 41 | 42 | 5. **Configure Google OAuth** 43 | - Add your Google OAuth provider details (Client ID and Client Secret) in the PocketBase admin panel under the "Auth Providers" section. 44 | 45 | --- 46 | 47 | ### Running the Project 48 | 49 | Once the setup is complete, everything should work as expected. Start your PocketBase server using `make db` and run the project. 50 | 51 | --- 52 | 53 | ## Notes 54 | 55 | - Ensure that your PocketBase server is running before interacting with the project. 56 | - If you encounter any issues, double-check the schema and OAuth provider configuration. 57 | 58 | --- 59 | 60 | Enjoy building with **Gunter**! 🎉 61 | 62 | 63 | ## Docker 64 | 65 | ### How to Use 66 | 1. Build the Docker Image 67 | ```bash 68 | cd /path/to/your/t3-clone 69 | docker build -t morethancoder/t3-clone . 70 | ``` 71 | 2. Run with Default Settings 72 | ```bash 73 | docker run -p 8080:8080 -p 8090:8090 morethancoder/t3-clone 74 | ``` 75 | 3. Run with Custom Environment Variables 76 | ```bash 77 | docker run -p 8080:8080 -p 8090:8090 -e OPENROUTER_API_KEY=your_actual_api_key -e ENV=production morethancoder/t3-clone 78 | ``` 79 | 4. Run with Persistent Database 80 | ```bash 81 | docker run -p 8080:8080 -p 8090:8090 -v $(pwd)/pb_data:/root/pb_data -e OPENROUTER_API_KEY=your_actual_api_key morethancoder/t3-clone 82 | ``` 83 | 5. Run in Background (Detached Mode) 84 | ```bash 85 | docker run -d -p 8080:8080 -p 8090:8090 --name t3-clone-app -e OPENROUTER_API_KEY=your_actual_api_key morethancoder/t3-clone 86 | ``` 87 | 88 | #### Access Your Application 89 | ``` 90 | 91 | Main app: http://localhost:8080 92 | Database server: http://localhost:8090 93 | ``` 94 | #### Stop the Container 95 | ```bash 96 | docker stop t3-clone-app 97 | docker rm t3-clone-app 98 | ``` 99 | -------------------------------------------------------------------------------- /views/components/Background.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | 4 | templ BackgroundBlobs() { 5 | 67 |
68 |
69 |
70 |
71 |
72 |
73 | } 74 | 75 | -------------------------------------------------------------------------------- /utils/loggger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type Logger interface { 12 | Info(format string, args ...any) 13 | Success(format string, args ...any) 14 | Error(format string, args ...any) 15 | Loading(format string, args ...any) 16 | Warn(format string, args ...any) 17 | Debug(format string, args ...any) 18 | Fatal(format string, args ...any) 19 | } 20 | 21 | type logMode string 22 | 23 | const ( 24 | infoMode logMode = "INFO" 25 | successMode logMode = "SUCCESS" 26 | errorMode logMode = "ERROR" 27 | loadingMode logMode = "LOADING" 28 | warnMode logMode = "WARN" 29 | debugMode logMode = "DEBUG" 30 | fatalMode logMode = "FATAL" 31 | ) 32 | 33 | const ( 34 | Reset = "\033[0m" 35 | Primary = "\033[33m" // Yellow (used for warnings) 36 | Secondary = "\033[36m" // Cyan (used for debug) 37 | Accent = "\033[37m" // White (used for loading) 38 | InfoColor = "\033[96m" // Bright Cyan (used for info) 39 | Success = "\033[32m" // Green (used for success) 40 | Warning = "\033[93m" // Bright Yellow (used for warnings) 41 | Error = "\033[31m" // Red (used for errors) 42 | FatalColor = "\033[35m" // Magenta (used for fatal) 43 | Dim = "\033[2m" // Dim text 44 | ) 45 | 46 | type logger struct { 47 | Out io.Writer 48 | DebugMode bool 49 | mutex sync.Mutex 50 | } 51 | 52 | var Log *logger 53 | 54 | func init() { 55 | Log = NewLogger() 56 | } 57 | 58 | func NewLogger() *logger { 59 | return &logger{ 60 | Out: os.Stdout, 61 | } 62 | } 63 | 64 | func (l *logger) prefix(mode logMode, color string) string { 65 | timeNow := fmt.Sprintf("%s%s%s", Accent, time.Now().Format(time.Kitchen), Reset) 66 | return fmt.Sprintf("[%s] %s%s%s ", timeNow, color, mode, Reset) 67 | } 68 | 69 | func (l *logger) log(mode logMode, color string, format string, args ...any) { 70 | l.mutex.Lock() 71 | defer l.mutex.Unlock() 72 | message := fmt.Sprintf(format, args...) 73 | _, err := l.Out.Write([]byte(l.prefix(mode, color) + Dim + message + Reset + "\n")) 74 | if err != nil { 75 | _, _ = fmt.Printf("Logging error: %v\n", err) 76 | } 77 | } 78 | 79 | func (l *logger) Info(format string, args ...any) { 80 | l.log(infoMode, InfoColor, format, args...) 81 | } 82 | 83 | func (l *logger) Success(format string, args ...any) { 84 | l.log(successMode, Success, format, args...) 85 | } 86 | 87 | func (l *logger) Error(format string, args ...any) { 88 | l.log(errorMode, Error, format, args...) 89 | } 90 | 91 | func (l *logger) Loading(format string, args ...any) { 92 | l.log(loadingMode, Accent, format, args...) 93 | } 94 | 95 | func (l *logger) Warn(format string, args ...any) { 96 | l.log(warnMode, Warning, format, args...) 97 | } 98 | 99 | func (l *logger) Debug(format string, args ...any) { 100 | if l.DebugMode { 101 | l.log(debugMode, Secondary, format, args...) 102 | } 103 | } 104 | 105 | func (l *logger) Fatal(format string, args ...any) { 106 | l.log(fatalMode, FatalColor, format, args...) 107 | os.Exit(1) 108 | } 109 | -------------------------------------------------------------------------------- /views/pages/AuthRedirect_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package pages 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 "morethancoder/t3-clone/views/components" 12 | 13 | func AuthRedirect(title string) 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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") 35 | if templ_7745c5c3_Err != nil { 36 | return templ_7745c5c3_Err 37 | } 38 | templ_7745c5c3_Err = components.IconLoading("size-6").Render(ctx, templ_7745c5c3_Buffer) 39 | if templ_7745c5c3_Err != nil { 40 | return templ_7745c5c3_Err 41 | } 42 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") 43 | if templ_7745c5c3_Err != nil { 44 | return templ_7745c5c3_Err 45 | } 46 | var templ_7745c5c3_Var2 string 47 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) 48 | if templ_7745c5c3_Err != nil { 49 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages/AuthRedirect.templ`, Line: 20, Col: 50} 50 | } 51 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 52 | if templ_7745c5c3_Err != nil { 53 | return templ_7745c5c3_Err 54 | } 55 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Please wait a moment to be redirected

") 56 | if templ_7745c5c3_Err != nil { 57 | return templ_7745c5c3_Err 58 | } 59 | templ_7745c5c3_Err = components.BackgroundBlobs().Render(ctx, templ_7745c5c3_Buffer) 60 | if templ_7745c5c3_Err != nil { 61 | return templ_7745c5c3_Err 62 | } 63 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") 64 | if templ_7745c5c3_Err != nil { 65 | return templ_7745c5c3_Err 66 | } 67 | return nil 68 | }) 69 | } 70 | 71 | var _ = templruntime.GeneratedTemplate 72 | -------------------------------------------------------------------------------- /views/components/ChatBubble.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | templ Chat(bubbles []templ.Component) { 9 |
10 | for _, bubble := range bubbles { 11 | @bubble 12 | } 13 |
14 | } 15 | 16 | type ChatBubbleData struct { 17 | ID string 18 | Role string 19 | Type string 20 | Text string 21 | ImageUrl string 22 | Timestamp string 23 | Status string 24 | Model string 25 | Company string 26 | } 27 | 28 | templ ChatBubble(data ChatBubbleData) { 29 | if data.Role == "loading" { 30 |
31 |
32 | switch strings.ToLower(data.Company) { 33 | case "google": 34 | @LogoGemini("size-7 text-accent animate-spin glow") 35 | case "openai": 36 | @LogoGPT("size-7 text-accent animate-spin glow") 37 | case "anthropic": 38 | @LogoClaude("size-7 text-accent animate-spin glow") 39 | case "deepseek": 40 | @LogoDeepSeek("size-7 text-accent animate-spin glow") 41 | default: 42 | @LogoGPT("size-7 text-accent animate-spin glow") 43 | } 44 |
45 |
46 | { data.Model } 47 |
48 |
49 | Typing... 50 |
51 |
52 | } else if data.Role == "error" { 53 |
54 |
55 | switch strings.ToLower(data.Company) { 56 | case "google": 57 | @LogoGemini("size-7 text-accent animate-pulse glow") 58 | case "openai": 59 | @LogoGPT("size-7 text-accent animate-pulse glow") 60 | case "anthropic": 61 | @LogoClaude("size-7 text-accent animate-pulse glow") 62 | case "deepseek": 63 | @LogoDeepSeek("size-7 text-accent animate-pulse glow") 64 | default: 65 | @LogoGPT("size-7 text-accent animate-pulse glow") 66 | } 67 |
68 |
69 | { data.Model } 70 |
71 |
72 | { data.Text } 73 |
74 |
75 | } else if data.Role == "user" { 76 |
77 |
78 | You 79 | 80 |
81 |
82 | { data.Text } 83 |
84 |
85 | } else { 86 |
87 |
88 | switch strings.ToLower(data.Company) { 89 | case "google": 90 | @LogoGemini("size-7 text-accent animate-pulse glow") 91 | case "openai": 92 | @LogoGPT("size-7 text-accent animate-pulse glow") 93 | case "anthropic": 94 | @LogoClaude("size-7 text-accent animate-pulse glow") 95 | case "deepseek": 96 | @LogoDeepSeek("size-7 text-accent animate-pulse glow") 97 | default: 98 | @LogoGPT("size-7 text-accent animate-pulse glow") 99 | } 100 |
101 |
102 | { data.Model } 103 | 104 |
105 |
109 | { data.Text } 110 |
111 |
112 | } 113 | } 114 | -------------------------------------------------------------------------------- /views/components/Background_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package components 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 BackgroundBlobs() 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, "
") 33 | if templ_7745c5c3_Err != nil { 34 | return templ_7745c5c3_Err 35 | } 36 | return nil 37 | }) 38 | } 39 | 40 | var _ = templruntime.GeneratedTemplate 41 | -------------------------------------------------------------------------------- /handlers/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "morethancoder/t3-clone/db" 6 | "morethancoder/t3-clone/services" 7 | "morethancoder/t3-clone/utils" 8 | "morethancoder/t3-clone/views/layouts" 9 | "morethancoder/t3-clone/views/pages" 10 | "net/http" 11 | "os" 12 | 13 | datastar "github.com/starfederation/datastar/sdk/go" 14 | ) 15 | 16 | func GETAuthRedirect() http.HandlerFunc { 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | err := layouts.MainLayout(pages.AuthRedirect("Authenticating...")).Render(r.Context(), w) 19 | if err != nil { 20 | utils.Log.Error(err.Error()) 21 | http.Error(w, "Something went wrong", http.StatusInternalServerError) 22 | return 23 | } 24 | } 25 | } 26 | 27 | func POSTAuthRedirect() http.HandlerFunc { 28 | return func(w http.ResponseWriter, r *http.Request) { 29 | _, err := r.Cookie("jwt") 30 | if err == nil { 31 | utils.Log.Info("Already authenticated") 32 | sse := datastar.NewSSE(w, r) 33 | sse.ExecuteScript("window.location.href = '/'") 34 | return 35 | } 36 | //get user session 37 | cookie, err := r.Cookie(KeySessionID) 38 | if err != nil { 39 | utils.Log.Error(err.Error()) 40 | sse := datastar.NewSSE(w, r) 41 | sse.ExecuteScript("window.location.href = '/login'") 42 | return 43 | } 44 | 45 | //get search params 46 | state := r.URL.Query().Get("state") 47 | code := r.URL.Query().Get("code") 48 | 49 | session := services.Session{ 50 | ID: cookie.Value, 51 | } 52 | 53 | ok := session.Load() 54 | if !ok { 55 | utils.Log.Error("Session not found") 56 | sse := datastar.NewSSE(w, r) 57 | sse.ExecuteScript("window.location.href = '/login'") 58 | return 59 | } 60 | 61 | //get auth providers 62 | authProviders, ok := session.Data[KeyOAuth2Providers].([]db.OAuth2Provider) 63 | if !ok { 64 | utils.Log.Error("Auth providers not found") 65 | sse := datastar.NewSSE(w, r) 66 | sse.ExecuteScript("window.location.href = '/login'") 67 | return 68 | } 69 | 70 | for _, provider := range authProviders { 71 | if provider.State == state { 72 | //authenticate 73 | utils.Log.Debug("Authenticating with %s code: %s", provider.Name, code) 74 | 75 | res, err := db.Db.AuthWithOAuth2(db.OAuth2AuthRequest{ 76 | Provider: provider.Name, 77 | Code: code, 78 | CodeVerifier: provider.CodeVerifier, 79 | RedirectURL: os.Getenv("APP_URL") + "/auth-redirect", 80 | }, map[db.QueryParam]string{}) 81 | 82 | if err != nil { 83 | utils.Log.Error("Failed to auth with %s: %s", provider.Name, err.Error()) 84 | sse := datastar.NewSSE(w, r) 85 | sse.ExecuteScript("window.location.href = '/login'") 86 | return 87 | } 88 | 89 | // save jwt as cookie to auto auth 90 | jwtCookie := &http.Cookie{ 91 | Name: "jwt", 92 | Value: res.Token, 93 | Path: "/", 94 | HttpOnly: true, 95 | SameSite: http.SameSiteStrictMode, 96 | } 97 | 98 | if os.Getenv("ENV") != "dev" { 99 | jwtCookie.Secure = true 100 | } 101 | 102 | modelrecords, err := db.Db.GetModelRecords(map[db.QueryParam]string{}) 103 | if err != nil { 104 | utils.Log.Error(err.Error()) 105 | http.Error(w, "Failed to get models", http.StatusInternalServerError) 106 | return 107 | } 108 | 109 | ctx := context.WithValue(r.Context(), "AuthRecord", res.Record) 110 | ctx = context.WithValue(ctx, "Models", db.GroupModelRecordsByCompany(modelrecords.Items)) 111 | r = r.WithContext(ctx) 112 | 113 | http.SetCookie(w, jwtCookie) 114 | 115 | sse := datastar.NewSSE(w, r) 116 | //redirect to home 117 | err = sse.MergeFragmentTempl(pages.HomePage()) 118 | if err != nil { 119 | utils.Log.Debug("Failed to merge fragment: %s", err.Error()) 120 | sse.ExecuteScript("window.location.href = '/'") 121 | } 122 | return 123 | 124 | } 125 | } 126 | 127 | utils.Log.Error("State not found") 128 | sse := datastar.NewSSE(w, r) 129 | sse.ExecuteScript("window.location.href = '/login'") 130 | //maybe render an update in the frontend 131 | return 132 | } 133 | } 134 | 135 | func GETSignOut() http.HandlerFunc { 136 | return func(w http.ResponseWriter, r *http.Request) { 137 | http.SetCookie(w, &http.Cookie{ 138 | Name: "jwt", 139 | Path: "/", 140 | MaxAge: -1, 141 | HttpOnly: true, 142 | SameSite: http.SameSiteStrictMode, 143 | }) 144 | http.Redirect(w, r, "/login", http.StatusSeeOther) 145 | return 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /db/auth.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | ) 12 | 13 | type QueryParam string 14 | 15 | const ( 16 | QueryParamExpand QueryParam = "expand" 17 | QueryParamFields QueryParam = "fields" 18 | ) 19 | 20 | type AuthRecord struct { 21 | CollectionID string `json:"collectionId"` 22 | CollectionName string `json:"collectionName"` 23 | ID string `json:"id"` 24 | Email string `json:"email"` 25 | EmailVisibility bool `json:"emailVisibility"` 26 | Verified bool `json:"verified"` 27 | Name string `json:"name"` 28 | Avatar string `json:"avatar"` 29 | Created string `json:"created"` 30 | Updated string `json:"updated"` 31 | } 32 | 33 | type OAuth2AuthRequest struct { 34 | Provider string `json:"provider"` 35 | Code string `json:"code"` 36 | CodeVerifier string `json:"codeVerifier"` 37 | RedirectURL string `json:"redirectURL"` 38 | CreateData map[string]interface{} `json:"createData,omitempty"` 39 | } 40 | 41 | type OAuth2AuthResponse struct { 42 | Token string `json:"token"` 43 | Record AuthRecord `json:"record"` 44 | Meta struct { 45 | ID string `json:"id"` 46 | Name string `json:"name"` 47 | Username string `json:"username"` 48 | Email string `json:"email"` 49 | AvatarURL string `json:"avatarURL"` 50 | AccessToken string `json:"accessToken"` 51 | RefreshToken string `json:"refreshToken"` 52 | Expiry string `json:"expiry"` 53 | IsNew bool `json:"isNew"` 54 | RawUser map[string]interface{} `json:"rawUser"` 55 | } `json:"meta"` 56 | } 57 | 58 | func (db *db) AuthWithOAuth2(request OAuth2AuthRequest, queryParams map[QueryParam]string) (*OAuth2AuthResponse, error) { 59 | authEndpoint := "/api/collections/users/auth-with-oauth2" 60 | 61 | queryString := "" 62 | if len(queryParams) > 0 { 63 | query := url.Values{} 64 | for key, value := range queryParams { 65 | query.Add(string(key), value) 66 | } 67 | queryString = "?" + query.Encode() 68 | } 69 | 70 | requestBody, err := json.Marshal(request) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | req, err := http.NewRequest("POST", db.Url+authEndpoint+queryString, bytes.NewBuffer(requestBody)) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | req.Header.Set("Content-Type", "application/json") 81 | 82 | resp, err := db.Client.Do(req) 83 | if err != nil { 84 | return nil, err 85 | } 86 | defer resp.Body.Close() 87 | 88 | respBody, err := io.ReadAll(resp.Body) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if resp.StatusCode != http.StatusOK { 94 | return nil, errors.New(string(respBody)) 95 | } 96 | 97 | var authResponse OAuth2AuthResponse 98 | if err := json.Unmarshal(respBody, &authResponse); err != nil { 99 | return nil, err 100 | } 101 | 102 | return &authResponse, nil 103 | } 104 | 105 | 106 | // AuthRefreshResponse represents the response structure for the auth-refresh endpoint 107 | type AuthRefreshResponse struct { 108 | Token string `json:"token"` 109 | Record AuthRecord `json:"record"` 110 | } 111 | 112 | func (d *db) AuthRefresh(token string) (*AuthRefreshResponse, error) { 113 | if token == "" { 114 | return nil, errors.New("[ERROR] Authorization token is required") 115 | } 116 | 117 | url := fmt.Sprintf("%s/api/collections/users/auth-refresh", d.Url) 118 | 119 | req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte{})) 120 | if err != nil { 121 | return nil, fmt.Errorf("[ERROR] failed to create request: %w", err) 122 | } 123 | 124 | req.Header.Set("Authorization", "Bearer "+token) 125 | req.Header.Set("Content-Type", "application/json") 126 | 127 | resp, err := d.Client.Do(req) 128 | if err != nil { 129 | return nil, fmt.Errorf("[ERROR] failed to make request: %w", err) 130 | } 131 | defer resp.Body.Close() 132 | 133 | if resp.StatusCode != http.StatusOK { 134 | body, _ := io.ReadAll(resp.Body) 135 | return nil, fmt.Errorf("[ERROR] request failed with status %d: %s", resp.StatusCode, string(body)) 136 | } 137 | 138 | var authResponse AuthRefreshResponse 139 | if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil { 140 | return nil, fmt.Errorf("[ERROR] failed to parse response: %w", err) 141 | } 142 | 143 | return &authResponse, nil 144 | } 145 | -------------------------------------------------------------------------------- /services/openrouter.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "morethancoder/t3-clone/utils" 9 | "net/http" 10 | "os" 11 | ) 12 | 13 | type OpenRouterInstance struct { 14 | APIKey string 15 | Url string 16 | Client *http.Client 17 | } 18 | 19 | var OpenRouter = NewOpenRouter() 20 | 21 | func NewOpenRouter() *OpenRouterInstance { 22 | apiKey := os.Getenv("OPENROUTER_API_KEY") 23 | if apiKey == "" { 24 | utils.Log.Fatal("OPENROUTER_API_KEY is not set") 25 | } 26 | return &OpenRouterInstance{ 27 | APIKey: apiKey, 28 | Url: "https://openrouter.ai/api/v1/chat/completions", 29 | Client: &http.Client{}, 30 | } 31 | } 32 | 33 | type ImageURL struct { 34 | URL string `json:"url"` 35 | } 36 | 37 | type Content struct { 38 | Type string `json:"type"` 39 | Text string `json:"text,omitempty"` 40 | ImageURL *ImageURL `json:"image_url,omitempty"` 41 | } 42 | 43 | type Message struct { 44 | Role string `json:"role"` 45 | Content interface{} `json:"content"` // Can be string or []Content 46 | Refusal *string `json:"refusal"` 47 | Reasoning *string `json:"reasoning"` 48 | } 49 | 50 | type OpenRouterRequest struct { 51 | Model string `json:"model"` 52 | Messages []Message `json:"messages"` 53 | } 54 | 55 | type Choice struct { 56 | Logprobs interface{} `json:"logprobs"` 57 | FinishReason string `json:"finish_reason"` 58 | NativeFinishReason string `json:"native_finish_reason"` 59 | Index int `json:"index"` 60 | Message Message `json:"message"` 61 | } 62 | 63 | type UsageDetails struct { 64 | CachedTokens int `json:"cached_tokens"` 65 | } 66 | 67 | type CompletionTokensDetails struct { 68 | ReasoningTokens int `json:"reasoning_tokens"` 69 | } 70 | 71 | type Usage struct { 72 | PromptTokens int `json:"prompt_tokens"` 73 | CompletionTokens int `json:"completion_tokens"` 74 | TotalTokens int `json:"total_tokens"` 75 | PromptTokensDetails UsageDetails `json:"prompt_tokens_details"` 76 | CompletionTokensDetails CompletionTokensDetails `json:"completion_tokens_details"` 77 | } 78 | 79 | type OpenRouterResponse struct { 80 | ID string `json:"id"` 81 | Provider string `json:"provider"` 82 | Model string `json:"model"` 83 | Object string `json:"object"` 84 | Created int64 `json:"created"` 85 | Choices []Choice `json:"choices"` 86 | SystemFingerprint string `json:"system_fingerprint"` 87 | Usage Usage `json:"usage"` 88 | } 89 | 90 | func (o *OpenRouterInstance) Request(req OpenRouterRequest) (*OpenRouterResponse, error) { 91 | jsonData, err := json.Marshal(req) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | httpReq, err := http.NewRequest("POST", o.Url, bytes.NewBuffer(jsonData)) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | httpReq.Header.Set("Content-Type", "application/json") 102 | httpReq.Header.Set("Authorization", "Bearer "+o.APIKey) 103 | 104 | httpRes, err := o.Client.Do(httpReq) 105 | if err != nil { 106 | return nil, err 107 | } 108 | defer httpRes.Body.Close() 109 | 110 | body, err := io.ReadAll(httpRes.Body) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | if httpRes.StatusCode != 200 { 116 | return nil, fmt.Errorf("non-200 status code: %d, body: %s", httpRes.StatusCode, body) 117 | } 118 | 119 | var response OpenRouterResponse 120 | if err := json.Unmarshal(body, &response); err != nil { 121 | return nil, fmt.Errorf("failed to unmarshal response: %w", err) 122 | } 123 | 124 | return &response, nil 125 | } 126 | 127 | func (o *OpenRouterInstance) RequestStream(req OpenRouterRequest, handler func(data []byte)) error { 128 | jsonData, err := json.Marshal(req) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | httpReq, err := http.NewRequest("POST", o.Url, bytes.NewBuffer(jsonData)) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | httpReq.Header.Set("Content-Type", "application/json") 139 | httpReq.Header.Set("Authorization", "Bearer "+o.APIKey) 140 | 141 | httpRes, err := o.Client.Do(httpReq) 142 | if err != nil { 143 | return err 144 | } 145 | defer httpRes.Body.Close() 146 | 147 | if httpRes.StatusCode != 200 { 148 | body, _ := io.ReadAll(httpRes.Body) 149 | return fmt.Errorf("non-200 status code: %d, body: %s", httpRes.StatusCode, body) 150 | } 151 | 152 | decoder := json.NewDecoder(httpRes.Body) 153 | for { 154 | var chunk map[string]interface{} 155 | if err := decoder.Decode(&chunk); err == io.EOF { 156 | break 157 | } else if err != nil { 158 | return err 159 | } 160 | 161 | chunkData, err := json.Marshal(chunk) 162 | if err != nil { 163 | return err 164 | } 165 | 166 | handler(chunkData) 167 | } 168 | 169 | return nil 170 | } 171 | -------------------------------------------------------------------------------- /views/components/Alert_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package components 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 | type AlertData struct { 12 | Level string 13 | Message string 14 | Icon templ.Component 15 | } 16 | 17 | func Alert(data AlertData) 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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
$_showAlert=false, 5000)\" data-class=\"{ \n 'opacity-0 translate-y-[-100%]' : !$_showAlert,\n }\">") 94 | if templ_7745c5c3_Err != nil { 95 | return templ_7745c5c3_Err 96 | } 97 | if data.Icon != nil { 98 | templ_7745c5c3_Err = data.Icon.Render(ctx, templ_7745c5c3_Buffer) 99 | if templ_7745c5c3_Err != nil { 100 | return templ_7745c5c3_Err 101 | } 102 | } 103 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") 104 | if templ_7745c5c3_Err != nil { 105 | return templ_7745c5c3_Err 106 | } 107 | var templ_7745c5c3_Var2 string 108 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Message) 109 | if templ_7745c5c3_Err != nil { 110 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/components/Alert.templ`, Line: 51, Col: 22} 111 | } 112 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 113 | if templ_7745c5c3_Err != nil { 114 | return templ_7745c5c3_Err 115 | } 116 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") 117 | if templ_7745c5c3_Err != nil { 118 | return templ_7745c5c3_Err 119 | } 120 | return nil 121 | }) 122 | } 123 | 124 | var _ = templruntime.GeneratedTemplate 125 | -------------------------------------------------------------------------------- /handlers/chat.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "morethancoder/t3-clone/db" 8 | "morethancoder/t3-clone/services" 9 | "morethancoder/t3-clone/utils" 10 | "morethancoder/t3-clone/views/components" 11 | "net/http" 12 | "strings" 13 | "time" 14 | 15 | "github.com/a-h/templ" 16 | "github.com/google/uuid" 17 | datastar "github.com/starfederation/datastar/sdk/go" 18 | "github.com/yuin/goldmark" 19 | ) 20 | 21 | type Data struct { 22 | Chat []struct { 23 | Content []struct { 24 | Text string `json:"text"` 25 | Type string `json:"type"` 26 | } `json:"content"` 27 | Role string `json:"role"` 28 | } `json:"chat"` 29 | Model string `json:"model"` 30 | } 31 | 32 | func randomString() string { 33 | return uuid.NewString() 34 | } 35 | 36 | func GETNewChat(w http.ResponseWriter, r *http.Request) { 37 | jwtCookie, err := r.Cookie("jwt") 38 | if err != nil { 39 | utils.Log.Error("No jwt cookie found") 40 | http.Redirect(w, r, "/login", http.StatusSeeOther) 41 | return 42 | } 43 | 44 | authRefreshResponse, err := db.Db.AuthRefresh(jwtCookie.Value) 45 | if err != nil { 46 | utils.Log.Error(err.Error()) 47 | http.SetCookie(w, &http.Cookie{ 48 | Name: "jwt", 49 | MaxAge: -1, 50 | HttpOnly: true, 51 | }) 52 | http.Error(w, "Failed to auth refresh", http.StatusUnauthorized) 53 | return 54 | } 55 | services.UserSSEHub.ExcuteScript(authRefreshResponse.Record.ID, "window.location.reload()") 56 | return 57 | } 58 | 59 | func POSTChat(w http.ResponseWriter, r *http.Request) { 60 | jwtCookie, err := r.Cookie("jwt") 61 | if err != nil { 62 | utils.Log.Error("No jwt cookie found") 63 | http.Redirect(w, r, "/login", http.StatusSeeOther) 64 | return 65 | } 66 | 67 | authRefreshResponse, err := db.Db.AuthRefresh(jwtCookie.Value) 68 | if err != nil { 69 | utils.Log.Error(err.Error()) 70 | http.SetCookie(w, &http.Cookie{ 71 | Name: "jwt", 72 | MaxAge: -1, 73 | HttpOnly: true, 74 | }) 75 | http.Error(w, "Failed to auth refresh", http.StatusUnauthorized) 76 | return 77 | } 78 | 79 | userData := &Data{} 80 | err = datastar.ReadSignals(r, userData) 81 | if err != nil { 82 | utils.Log.Error(err.Error()) 83 | return 84 | } 85 | 86 | var chatBubbles []templ.Component 87 | var msgs []services.Message 88 | 89 | for _, chat := range userData.Chat { 90 | chatBubbles = append(chatBubbles, components.ChatBubble(components.ChatBubbleData{ 91 | ID: randomString(), 92 | Company: strings.Split(userData.Model, "/")[0], 93 | Role: chat.Role, 94 | Type: chat.Content[0].Type, 95 | Text: chat.Content[0].Text, 96 | Timestamp: time.Now().Format("3:04 PM"), 97 | Model: userData.Model, 98 | })) 99 | msgs = append(msgs, services.Message{ 100 | Role: chat.Role, 101 | Content: []services.Content{services.Content{Type: chat.Content[0].Type, Text: chat.Content[0].Text}}, 102 | }) 103 | } 104 | 105 | chatBubbles = append(chatBubbles, components.ChatBubble(components.ChatBubbleData{ 106 | ID: randomString(), 107 | Model: userData.Model, 108 | Role: "loading", 109 | Company: strings.Split(userData.Model, "/")[0], 110 | })) 111 | 112 | //rendering the prompt 113 | services.UserSSEHub.BroadcastFragments(authRefreshResponse.Record.ID, 114 | components.Chat(chatBubbles), 115 | ) 116 | 117 | chatBubbles = chatBubbles[:len(chatBubbles)-1] 118 | 119 | req := services.OpenRouterRequest{ 120 | Model: userData.Model, 121 | Messages: msgs, 122 | } 123 | 124 | res, err := services.OpenRouter.Request(req) 125 | if err != nil { 126 | utils.Log.Error(err.Error()) 127 | chatBubbles = append(chatBubbles, components.ChatBubble(components.ChatBubbleData{ 128 | ID: randomString(), 129 | Role: "assistant", 130 | Type: "text", 131 | Text: err.Error(), 132 | Company: strings.Split(userData.Model, "/")[0], 133 | Model: userData.Model, 134 | Timestamp: time.Now().Format("3:04 PM"), 135 | })) 136 | 137 | services.UserSSEHub.BroadcastFragments(authRefreshResponse.Record.ID, 138 | components.Chat(chatBubbles), 139 | ) 140 | 141 | chatBubbles = chatBubbles[:len(chatBubbles)-1] 142 | return 143 | } 144 | 145 | for _, choice := range res.Choices { 146 | var buf bytes.Buffer 147 | err = goldmark.Convert([]byte(choice.Message.Content.(string)), &buf) 148 | if err != nil { 149 | utils.Log.Error(err.Error()) 150 | return 151 | } 152 | chatBubbles = append(chatBubbles, components.ChatBubble(components.ChatBubbleData{ 153 | ID: randomString(), 154 | Role: choice.Message.Role, 155 | Type: "text", 156 | Text: buf.String(), 157 | Company: res.Provider, 158 | Timestamp: time.Now().Format("3:04 PM"), 159 | Model: userData.Model, 160 | })) 161 | //update userdata chat 162 | userData.Chat = append(userData.Chat, struct { 163 | Content []struct { 164 | Text string "json:\"text\"" 165 | Type string "json:\"type\"" 166 | } "json:\"content\"" 167 | Role string "json:\"role\"" 168 | }{ 169 | Role: "Assistance", 170 | Content: []struct { 171 | Text string "json:\"text\"" 172 | Type string "json:\"type\"" 173 | }{ 174 | { 175 | Text: buf.String(), 176 | Type: choice.Message.Role, 177 | }, 178 | }, 179 | }) 180 | } 181 | 182 | updatedChat, err := json.Marshal(userData.Chat) 183 | if err != nil { 184 | utils.Log.Error(err.Error()) 185 | return 186 | } 187 | services.UserSSEHub.BroadcastSignals(authRefreshResponse.Record.ID, []byte(fmt.Sprintf(`{ chat : %s }`, string(updatedChat)))) 188 | 189 | services.UserSSEHub.BroadcastFragments(authRefreshResponse.Record.ID, 190 | components.Chat(chatBubbles), 191 | ) 192 | return 193 | } 194 | -------------------------------------------------------------------------------- /views/pages/LoginPage_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package pages 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 "morethancoder/t3-clone/views/components" 12 | import "context" 13 | import "morethancoder/t3-clone/db" 14 | import "os" 15 | 16 | func GetOAuth2Providers(ctx context.Context) []db.OAuth2Provider { 17 | data, ok := ctx.Value("OAuth2Providers").([]db.OAuth2Provider) 18 | if !ok { 19 | return []db.OAuth2Provider{} 20 | } else { 21 | return data 22 | } 23 | } 24 | 25 | func LoginPage() templ.Component { 26 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 27 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 28 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 29 | return templ_7745c5c3_CtxErr 30 | } 31 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 32 | if !templ_7745c5c3_IsBuffer { 33 | defer func() { 34 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 35 | if templ_7745c5c3_Err == nil { 36 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 37 | } 38 | }() 39 | } 40 | ctx = templ.InitializeContext(ctx) 41 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 42 | if templ_7745c5c3_Var1 == nil { 43 | templ_7745c5c3_Var1 = templ.NopComponent 44 | } 45 | ctx = templ.ClearChildren(ctx) 46 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") 47 | if templ_7745c5c3_Err != nil { 48 | return templ_7745c5c3_Err 49 | } 50 | templ_7745c5c3_Err = components.IconLockWithHole("size-5").Render(ctx, templ_7745c5c3_Buffer) 51 | if templ_7745c5c3_Err != nil { 52 | return templ_7745c5c3_Err 53 | } 54 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Welcome Back

Sign in to continue to your account

") 55 | if templ_7745c5c3_Err != nil { 56 | return templ_7745c5c3_Err 57 | } 58 | for _, provider := range GetOAuth2Providers(ctx) { 59 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") 73 | if templ_7745c5c3_Err != nil { 74 | return templ_7745c5c3_Err 75 | } 76 | switch provider.Name { 77 | case "google": 78 | templ_7745c5c3_Err = components.LogoGoogleColored("size-5").Render(ctx, templ_7745c5c3_Buffer) 79 | if templ_7745c5c3_Err != nil { 80 | return templ_7745c5c3_Err 81 | } 82 | case "github": 83 | templ_7745c5c3_Err = components.LogoGithub("size-5").Render(ctx, templ_7745c5c3_Buffer) 84 | if templ_7745c5c3_Err != nil { 85 | return templ_7745c5c3_Err 86 | } 87 | } 88 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Sign in with ") 89 | if templ_7745c5c3_Err != nil { 90 | return templ_7745c5c3_Err 91 | } 92 | var templ_7745c5c3_Var3 string 93 | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(provider.DisplayName) 94 | if templ_7745c5c3_Err != nil { 95 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages/LoginPage.templ`, Line: 46, Col: 56} 96 | } 97 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 98 | if templ_7745c5c3_Err != nil { 99 | return templ_7745c5c3_Err 100 | } 101 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") 102 | if templ_7745c5c3_Err != nil { 103 | return templ_7745c5c3_Err 104 | } 105 | } 106 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

By continuing, you agree to our Terms of Service

") 107 | if templ_7745c5c3_Err != nil { 108 | return templ_7745c5c3_Err 109 | } 110 | templ_7745c5c3_Err = components.BackgroundBlobs().Render(ctx, templ_7745c5c3_Buffer) 111 | if templ_7745c5c3_Err != nil { 112 | return templ_7745c5c3_Err 113 | } 114 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") 115 | if templ_7745c5c3_Err != nil { 116 | return templ_7745c5c3_Err 117 | } 118 | return nil 119 | }) 120 | } 121 | 122 | var _ = templruntime.GeneratedTemplate 123 | -------------------------------------------------------------------------------- /views/components/Logos.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ LogoGithub(class string) { 4 | 6 | 8 | 9 | 10 | } 11 | 12 | templ LogoGemini(class string) { 13 | 14 | 16 | 17 | 18 | } 19 | 20 | templ LogoMeta(class string) { 21 | 22 | 23 | 25 | 26 | 27 | } 28 | 29 | templ LogoClaude(class string) { 30 | 31 | 32 | 34 | 35 | 36 | } 37 | 38 | templ LogoDeepSeek(class string) { 39 | 41 | DeepSeek 42 | 44 | 45 | 46 | } 47 | 48 | templ LogoGPT(class string) { 49 | 50 | 51 | 53 | 54 | 55 | } 56 | 57 | templ LogoGoogleColored(class string) { 58 | 60 | 62 | 63 | 65 | 66 | 68 | 69 | 71 | 72 | 74 | 75 | 76 | } 77 | -------------------------------------------------------------------------------- /views/components/ChatBubble_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package components 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 | "fmt" 13 | "strings" 14 | ) 15 | 16 | func Chat(bubbles []templ.Component) 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") 38 | if templ_7745c5c3_Err != nil { 39 | return templ_7745c5c3_Err 40 | } 41 | for _, bubble := range bubbles { 42 | templ_7745c5c3_Err = bubble.Render(ctx, templ_7745c5c3_Buffer) 43 | if templ_7745c5c3_Err != nil { 44 | return templ_7745c5c3_Err 45 | } 46 | } 47 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") 48 | if templ_7745c5c3_Err != nil { 49 | return templ_7745c5c3_Err 50 | } 51 | return nil 52 | }) 53 | } 54 | 55 | type ChatBubbleData struct { 56 | ID string 57 | Role string 58 | Type string 59 | Text string 60 | ImageUrl string 61 | Timestamp string 62 | Status string 63 | Model string 64 | Company string 65 | } 66 | 67 | func ChatBubble(data ChatBubbleData) templ.Component { 68 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 69 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 70 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 71 | return templ_7745c5c3_CtxErr 72 | } 73 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 74 | if !templ_7745c5c3_IsBuffer { 75 | defer func() { 76 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 77 | if templ_7745c5c3_Err == nil { 78 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 79 | } 80 | }() 81 | } 82 | ctx = templ.InitializeContext(ctx) 83 | templ_7745c5c3_Var2 := templ.GetChildren(ctx) 84 | if templ_7745c5c3_Var2 == nil { 85 | templ_7745c5c3_Var2 = templ.NopComponent 86 | } 87 | ctx = templ.ClearChildren(ctx) 88 | if data.Role == "loading" { 89 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") 90 | if templ_7745c5c3_Err != nil { 91 | return templ_7745c5c3_Err 92 | } 93 | switch strings.ToLower(data.Company) { 94 | case "google": 95 | templ_7745c5c3_Err = LogoGemini("size-7 text-accent animate-spin glow").Render(ctx, templ_7745c5c3_Buffer) 96 | if templ_7745c5c3_Err != nil { 97 | return templ_7745c5c3_Err 98 | } 99 | case "openai": 100 | templ_7745c5c3_Err = LogoGPT("size-7 text-accent animate-spin glow").Render(ctx, templ_7745c5c3_Buffer) 101 | if templ_7745c5c3_Err != nil { 102 | return templ_7745c5c3_Err 103 | } 104 | case "anthropic": 105 | templ_7745c5c3_Err = LogoClaude("size-7 text-accent animate-spin glow").Render(ctx, templ_7745c5c3_Buffer) 106 | if templ_7745c5c3_Err != nil { 107 | return templ_7745c5c3_Err 108 | } 109 | case "deepseek": 110 | templ_7745c5c3_Err = LogoDeepSeek("size-7 text-accent animate-spin glow").Render(ctx, templ_7745c5c3_Buffer) 111 | if templ_7745c5c3_Err != nil { 112 | return templ_7745c5c3_Err 113 | } 114 | default: 115 | templ_7745c5c3_Err = LogoGPT("size-7 text-accent animate-spin glow").Render(ctx, templ_7745c5c3_Buffer) 116 | if templ_7745c5c3_Err != nil { 117 | return templ_7745c5c3_Err 118 | } 119 | } 120 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") 121 | if templ_7745c5c3_Err != nil { 122 | return templ_7745c5c3_Err 123 | } 124 | var templ_7745c5c3_Var3 string 125 | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Model) 126 | if templ_7745c5c3_Err != nil { 127 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/components/ChatBubble.templ`, Line: 46, Col: 22} 128 | } 129 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 130 | if templ_7745c5c3_Err != nil { 131 | return templ_7745c5c3_Err 132 | } 133 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
Typing...
") 134 | if templ_7745c5c3_Err != nil { 135 | return templ_7745c5c3_Err 136 | } 137 | } else if data.Role == "error" { 138 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") 139 | if templ_7745c5c3_Err != nil { 140 | return templ_7745c5c3_Err 141 | } 142 | switch strings.ToLower(data.Company) { 143 | case "google": 144 | templ_7745c5c3_Err = LogoGemini("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 145 | if templ_7745c5c3_Err != nil { 146 | return templ_7745c5c3_Err 147 | } 148 | case "openai": 149 | templ_7745c5c3_Err = LogoGPT("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 150 | if templ_7745c5c3_Err != nil { 151 | return templ_7745c5c3_Err 152 | } 153 | case "anthropic": 154 | templ_7745c5c3_Err = LogoClaude("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 155 | if templ_7745c5c3_Err != nil { 156 | return templ_7745c5c3_Err 157 | } 158 | case "deepseek": 159 | templ_7745c5c3_Err = LogoDeepSeek("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 160 | if templ_7745c5c3_Err != nil { 161 | return templ_7745c5c3_Err 162 | } 163 | default: 164 | templ_7745c5c3_Err = LogoGPT("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 165 | if templ_7745c5c3_Err != nil { 166 | return templ_7745c5c3_Err 167 | } 168 | } 169 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") 170 | if templ_7745c5c3_Err != nil { 171 | return templ_7745c5c3_Err 172 | } 173 | var templ_7745c5c3_Var4 string 174 | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Model) 175 | if templ_7745c5c3_Err != nil { 176 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/components/ChatBubble.templ`, Line: 69, Col: 22} 177 | } 178 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 179 | if templ_7745c5c3_Err != nil { 180 | return templ_7745c5c3_Err 181 | } 182 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") 183 | if templ_7745c5c3_Err != nil { 184 | return templ_7745c5c3_Err 185 | } 186 | var templ_7745c5c3_Var5 string 187 | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Text) 188 | if templ_7745c5c3_Err != nil { 189 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/components/ChatBubble.templ`, Line: 72, Col: 15} 190 | } 191 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 192 | if templ_7745c5c3_Err != nil { 193 | return templ_7745c5c3_Err 194 | } 195 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") 196 | if templ_7745c5c3_Err != nil { 197 | return templ_7745c5c3_Err 198 | } 199 | } else if data.Role == "user" { 200 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
You
") 214 | if templ_7745c5c3_Err != nil { 215 | return templ_7745c5c3_Err 216 | } 217 | var templ_7745c5c3_Var7 string 218 | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Text) 219 | if templ_7745c5c3_Err != nil { 220 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/components/ChatBubble.templ`, Line: 82, Col: 15} 221 | } 222 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 223 | if templ_7745c5c3_Err != nil { 224 | return templ_7745c5c3_Err 225 | } 226 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") 227 | if templ_7745c5c3_Err != nil { 228 | return templ_7745c5c3_Err 229 | } 230 | } else { 231 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") 232 | if templ_7745c5c3_Err != nil { 233 | return templ_7745c5c3_Err 234 | } 235 | switch strings.ToLower(data.Company) { 236 | case "google": 237 | templ_7745c5c3_Err = LogoGemini("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 238 | if templ_7745c5c3_Err != nil { 239 | return templ_7745c5c3_Err 240 | } 241 | case "openai": 242 | templ_7745c5c3_Err = LogoGPT("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 243 | if templ_7745c5c3_Err != nil { 244 | return templ_7745c5c3_Err 245 | } 246 | case "anthropic": 247 | templ_7745c5c3_Err = LogoClaude("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 248 | if templ_7745c5c3_Err != nil { 249 | return templ_7745c5c3_Err 250 | } 251 | case "deepseek": 252 | templ_7745c5c3_Err = LogoDeepSeek("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 253 | if templ_7745c5c3_Err != nil { 254 | return templ_7745c5c3_Err 255 | } 256 | default: 257 | templ_7745c5c3_Err = LogoGPT("size-7 text-accent animate-pulse glow").Render(ctx, templ_7745c5c3_Buffer) 258 | if templ_7745c5c3_Err != nil { 259 | return templ_7745c5c3_Err 260 | } 261 | } 262 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") 263 | if templ_7745c5c3_Err != nil { 264 | return templ_7745c5c3_Err 265 | } 266 | var templ_7745c5c3_Var8 string 267 | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Model) 268 | if templ_7745c5c3_Err != nil { 269 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/components/ChatBubble.templ`, Line: 102, Col: 22} 270 | } 271 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 272 | if templ_7745c5c3_Err != nil { 273 | return templ_7745c5c3_Err 274 | } 275 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") 289 | if templ_7745c5c3_Err != nil { 290 | return templ_7745c5c3_Err 291 | } 292 | var templ_7745c5c3_Var10 = []any{"prose chat-bubble " + data.ID} 293 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) 294 | if templ_7745c5c3_Err != nil { 295 | return templ_7745c5c3_Err 296 | } 297 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") 324 | if templ_7745c5c3_Err != nil { 325 | return templ_7745c5c3_Err 326 | } 327 | var templ_7745c5c3_Var13 string 328 | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(data.Text) 329 | if templ_7745c5c3_Err != nil { 330 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/components/ChatBubble.templ`, Line: 109, Col: 15} 331 | } 332 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) 333 | if templ_7745c5c3_Err != nil { 334 | return templ_7745c5c3_Err 335 | } 336 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") 337 | if templ_7745c5c3_Err != nil { 338 | return templ_7745c5c3_Err 339 | } 340 | } 341 | return nil 342 | }) 343 | } 344 | 345 | var _ = templruntime.GeneratedTemplate 346 | -------------------------------------------------------------------------------- /views/components/Icons.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ IconArrowUp(class string) { 4 | 5 | 6 | 14 | 15 | } 16 | 17 | templ IconSpeaker(class string) { 18 | 19 | 20 | 28 | 29 | } 30 | 31 | templ IconPlus(class string) { 32 | 33 | 34 | 42 | 43 | } 44 | 45 | templ IconWeb(class string) { 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | } 54 | 55 | templ IconBrain(class string) { 56 | 57 | 58 | 59 | 60 | 61 | 64 | 65 | 66 | } 67 | 68 | templ IconPaperclip(class string) { 69 | 70 | 71 | 79 | 80 | } 81 | 82 | 83 | 84 | templ IconSearch(class string) { 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | } 93 | 94 | templ IconGrad(class string) { 95 | 96 | 97 | 98 | 101 | 102 | 103 | 104 | } 105 | 106 | templ IconCode(class string) { 107 | 108 | 109 | 117 | 118 | } 119 | 120 | templ IconPlane(class string) { 121 | 122 | 123 | 131 | 132 | } 133 | 134 | templ IconChange(class string) { 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | } 144 | 145 | templ IconLaugh(class string) { 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | } 154 | 155 | templ IconList(class string) { 156 | 157 | 158 | 166 | 167 | } 168 | 169 | templ IconSquarePen(class string) { 170 | 171 | 172 | 173 | 174 | 177 | 178 | 179 | } 180 | 181 | templ IconPenTool(class string) { 182 | 183 | 184 | 185 | 188 | 191 | 192 | 193 | 194 | } 195 | 196 | templ IconNewspaper(class string) { 197 | 198 | 199 | 200 | 203 | 204 | 205 | 206 | } 207 | 208 | templ IconChevronDown(class string) { 209 | 210 | 211 | 219 | 220 | } 221 | 222 | templ IconHistory(class string) { 223 | 224 | } 225 | 226 | templ IconBookmark(class string) { 227 | 228 | } 229 | 230 | templ IconSettings(class string) { 231 | 232 | } 233 | 234 | templ IconPaintRoller(class string) { 235 | 236 | } 237 | 238 | templ IconKeyboard(class string) { 239 | 240 | } 241 | 242 | templ IconCPU(class string) { 243 | 244 | } 245 | 246 | templ IconX(class string) { 247 | 248 | } 249 | 250 | templ IconPin(class string) { 251 | 252 | } 253 | 254 | templ IconUnPin(class string) { 255 | 256 | } 257 | 258 | templ IconEye(class string) { 259 | 260 | } 261 | 262 | templ IconFileInput(class string) { 263 | 264 | } 265 | 266 | templ IconImagePlus(class string) { 267 | 268 | } 269 | 270 | 271 | 272 | templ IconLockWithHole(class string) { 273 | 274 | } 275 | 276 | templ IconLoading(class string) { 277 | 278 | } 279 | 280 | templ IconCircleAlert(class string) { 281 | 282 | } 283 | 284 | templ IconCircleCheck(class string) { 285 | 286 | } 287 | 288 | templ IconTriangleAlert(class string) { 289 | 290 | } 291 | 292 | templ IconCircleX(class string) { 293 | 294 | } 295 | -------------------------------------------------------------------------------- /views/components/Logos_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.898 4 | package components 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 LogoGithub(class string) 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 | var templ_7745c5c3_Var2 = []any{class} 33 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) 34 | if templ_7745c5c3_Err != nil { 35 | return templ_7745c5c3_Err 36 | } 37 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 51 | if templ_7745c5c3_Err != nil { 52 | return templ_7745c5c3_Err 53 | } 54 | return nil 55 | }) 56 | } 57 | 58 | func LogoGemini(class string) templ.Component { 59 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 60 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 61 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 62 | return templ_7745c5c3_CtxErr 63 | } 64 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 65 | if !templ_7745c5c3_IsBuffer { 66 | defer func() { 67 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 68 | if templ_7745c5c3_Err == nil { 69 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 70 | } 71 | }() 72 | } 73 | ctx = templ.InitializeContext(ctx) 74 | templ_7745c5c3_Var4 := templ.GetChildren(ctx) 75 | if templ_7745c5c3_Var4 == nil { 76 | templ_7745c5c3_Var4 = templ.NopComponent 77 | } 78 | ctx = templ.ClearChildren(ctx) 79 | var templ_7745c5c3_Var5 = []any{class} 80 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...) 81 | if templ_7745c5c3_Err != nil { 82 | return templ_7745c5c3_Err 83 | } 84 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") 98 | if templ_7745c5c3_Err != nil { 99 | return templ_7745c5c3_Err 100 | } 101 | return nil 102 | }) 103 | } 104 | 105 | func LogoMeta(class string) templ.Component { 106 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 107 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 108 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 109 | return templ_7745c5c3_CtxErr 110 | } 111 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 112 | if !templ_7745c5c3_IsBuffer { 113 | defer func() { 114 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 115 | if templ_7745c5c3_Err == nil { 116 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 117 | } 118 | }() 119 | } 120 | ctx = templ.InitializeContext(ctx) 121 | templ_7745c5c3_Var7 := templ.GetChildren(ctx) 122 | if templ_7745c5c3_Var7 == nil { 123 | templ_7745c5c3_Var7 = templ.NopComponent 124 | } 125 | ctx = templ.ClearChildren(ctx) 126 | var templ_7745c5c3_Var8 = []any{class} 127 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...) 128 | if templ_7745c5c3_Err != nil { 129 | return templ_7745c5c3_Err 130 | } 131 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") 145 | if templ_7745c5c3_Err != nil { 146 | return templ_7745c5c3_Err 147 | } 148 | return nil 149 | }) 150 | } 151 | 152 | func LogoClaude(class string) templ.Component { 153 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 154 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 155 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 156 | return templ_7745c5c3_CtxErr 157 | } 158 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 159 | if !templ_7745c5c3_IsBuffer { 160 | defer func() { 161 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 162 | if templ_7745c5c3_Err == nil { 163 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 164 | } 165 | }() 166 | } 167 | ctx = templ.InitializeContext(ctx) 168 | templ_7745c5c3_Var10 := templ.GetChildren(ctx) 169 | if templ_7745c5c3_Var10 == nil { 170 | templ_7745c5c3_Var10 = templ.NopComponent 171 | } 172 | ctx = templ.ClearChildren(ctx) 173 | var templ_7745c5c3_Var11 = []any{class} 174 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...) 175 | if templ_7745c5c3_Err != nil { 176 | return templ_7745c5c3_Err 177 | } 178 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") 192 | if templ_7745c5c3_Err != nil { 193 | return templ_7745c5c3_Err 194 | } 195 | return nil 196 | }) 197 | } 198 | 199 | func LogoDeepSeek(class string) templ.Component { 200 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 201 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 202 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 203 | return templ_7745c5c3_CtxErr 204 | } 205 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 206 | if !templ_7745c5c3_IsBuffer { 207 | defer func() { 208 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 209 | if templ_7745c5c3_Err == nil { 210 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 211 | } 212 | }() 213 | } 214 | ctx = templ.InitializeContext(ctx) 215 | templ_7745c5c3_Var13 := templ.GetChildren(ctx) 216 | if templ_7745c5c3_Var13 == nil { 217 | templ_7745c5c3_Var13 = templ.NopComponent 218 | } 219 | ctx = templ.ClearChildren(ctx) 220 | var templ_7745c5c3_Var14 = []any{class} 221 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) 222 | if templ_7745c5c3_Err != nil { 223 | return templ_7745c5c3_Err 224 | } 225 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "DeepSeek") 239 | if templ_7745c5c3_Err != nil { 240 | return templ_7745c5c3_Err 241 | } 242 | return nil 243 | }) 244 | } 245 | 246 | func LogoGPT(class string) templ.Component { 247 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 248 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 249 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 250 | return templ_7745c5c3_CtxErr 251 | } 252 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 253 | if !templ_7745c5c3_IsBuffer { 254 | defer func() { 255 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 256 | if templ_7745c5c3_Err == nil { 257 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 258 | } 259 | }() 260 | } 261 | ctx = templ.InitializeContext(ctx) 262 | templ_7745c5c3_Var16 := templ.GetChildren(ctx) 263 | if templ_7745c5c3_Var16 == nil { 264 | templ_7745c5c3_Var16 = templ.NopComponent 265 | } 266 | ctx = templ.ClearChildren(ctx) 267 | var templ_7745c5c3_Var17 = []any{class} 268 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...) 269 | if templ_7745c5c3_Err != nil { 270 | return templ_7745c5c3_Err 271 | } 272 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") 286 | if templ_7745c5c3_Err != nil { 287 | return templ_7745c5c3_Err 288 | } 289 | return nil 290 | }) 291 | } 292 | 293 | func LogoGoogleColored(class string) templ.Component { 294 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 295 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 296 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 297 | return templ_7745c5c3_CtxErr 298 | } 299 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 300 | if !templ_7745c5c3_IsBuffer { 301 | defer func() { 302 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 303 | if templ_7745c5c3_Err == nil { 304 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 305 | } 306 | }() 307 | } 308 | ctx = templ.InitializeContext(ctx) 309 | templ_7745c5c3_Var19 := templ.GetChildren(ctx) 310 | if templ_7745c5c3_Var19 == nil { 311 | templ_7745c5c3_Var19 = templ.NopComponent 312 | } 313 | ctx = templ.ClearChildren(ctx) 314 | var templ_7745c5c3_Var20 = []any{class} 315 | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) 316 | if templ_7745c5c3_Err != nil { 317 | return templ_7745c5c3_Err 318 | } 319 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") 333 | if templ_7745c5c3_Err != nil { 334 | return templ_7745c5c3_Err 335 | } 336 | return nil 337 | }) 338 | } 339 | 340 | var _ = templruntime.GeneratedTemplate 341 | -------------------------------------------------------------------------------- /views/pages/HomePage.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "fmt" 5 | "morethancoder/t3-clone/views/components" 6 | "context" 7 | "morethancoder/t3-clone/db" 8 | "strings" 9 | ) 10 | 11 | func GetAuthRecord(ctx context.Context) db.AuthRecord { 12 | data,ok := ctx.Value("AuthRecord").(db.AuthRecord) 13 | if !ok { 14 | return db.AuthRecord{} 15 | } 16 | return data 17 | } 18 | 19 | func GetModelsMap(ctx context.Context) map[string][]db.ModelRecord { 20 | data, ok := ctx.Value("Models").(map[string][]db.ModelRecord) 21 | if !ok { 22 | return map[string][]db.ModelRecord{ 23 | "google": []db.ModelRecord{ 24 | {ID: "gpt-3.5-turbo", Name: "GPT-3.5-turbo"}, 25 | }, 26 | } 27 | } 28 | return data 29 | } 30 | 31 | 32 | templ HomePage() { 33 |
39 |
40 | 41 | @HistoryBar() 42 | @ActionBar() 43 | @PromptBar() 44 | @MainSection(Starter()) 45 |
46 |
47 | } 48 | 49 | templ MainSection(content templ.Component) { 50 |
52 |
53 | @content 54 |
55 |
56 | } 57 | 58 | templ Starter() { 59 |
62 |
63 |
64 | @components.LogoGemini("size-8 text-accent animate-pulse glow") 65 | 66 |
67 |
68 | 106 |
107 |
108 | Good Morning! 109 | How can I help you!
110 |
111 | 121 |
122 |
123 | 126 |
127 |
128 | 131 |
132 |
133 | 136 |
137 |
138 | 141 |
142 |
143 | 146 |
147 |
148 |
149 | } 150 | 151 | templ StarterCard(icon templ.Component, text string) { 152 |
156 |
157 | @icon 158 |

{ text }

159 |
160 | 163 |
164 |
165 |
166 | } 167 | 168 | 169 | 170 | 171 | 172 | templ HistoryBar() { 173 |
177 |
178 | 181 |
182 | @SlideCard(SlideCardData{ 183 | dir: "left", 184 | id: "history", 185 | title: "History", 186 | icon: components.IconHistory("size-5"), 187 | content: History(), 188 | }) 189 | @SlideCard(SlideCardData{ 190 | dir: "left", 191 | id: "bookmarks", 192 | title: "Bookmarks", 193 | icon: components.IconBookmark("size-5"), 194 | content: Bookmarks(), 195 | }) 196 |
197 | } 198 | 199 | templ Bookmarks() { 200 |

Bookmarks

201 | 205 | 210 | 211 | } 212 | 213 | templ History() { 214 |

History

215 | 219 | 243 | } 244 | 245 | templ HistoryItem(text string) { 246 |
  • 249 | 250 | { text } 251 | 252 |
    257 | 260 | 263 |
    264 | 265 |
  • 266 | } 267 | 268 | type SlideCardData struct { 269 | id, title, dir string 270 | icon templ.Component 271 | content templ.Component 272 | } 273 | 274 | templ SlideCard(data SlideCardData) { 275 |
    280 |
    291 | 301 |
    302 |
    {$_%sHover=false}, 300)",data.id) } 305 | 306 | 307 | 308 | if data.dir == "left" { 309 | 310 | data-class={ fmt.Sprintf(` { 311 | 'opacity-100 translate-x-0' : ($_%[1]sClick||$_%[1]sHover), 312 | 'opacity-0 translate-x-[-120%%]' : (!$_%[1]sClick && !$_%[1]sHover) 313 | }`, data.id) } 314 | 315 | class="card card-md bg-base-100/50 backdrop-blur 316 | border-1 border-base-content/10 317 | shadow-sm shadow-base-content/20 318 | rounded-2xl z-10 319 | absolute top-0 left-0 320 | transition-all duration-300 translate-x-[-120%] 321 | opacity-0 322 | transform" 323 | } else { 324 | data-class={ fmt.Sprintf(` { 325 | 'opacity-100 translate-x-0' : ($_%[1]sClick||$_%[1]sHover), 326 | 'opacity-0 translate-x-[120%%]' : (!$_%[1]sClick && !$_%[1]sHover) 327 | }`, data.id) } 328 | 329 | class="card card-md bg-base-100/50 backdrop-blur 330 | border-1 border-base-content/10 331 | shadow-sm shadow-base-content/20 332 | rounded-2xl z-10 333 | absolute top-0 right-0 334 | transition-all duration-300 translate-x-[120%] 335 | opacity-0 336 | transform" 337 | } 338 | 339 | > 340 |
    341 | @data.content 342 |
    343 |
    344 |
    345 | } 346 | 347 | templ Settings() { 348 |

    Settings

    349 | 411 | } 412 | 413 | templ Theme() { 414 |
    Theme
    415 | } 416 | 417 | templ Keybinds() { 418 |
    Keybinds
    419 | } 420 | 421 | 422 | type ModelCardData struct { 423 | id string 424 | name string 425 | company string 426 | badges []templ.Component 427 | color Color 428 | } 429 | templ ModelCard(data ModelCardData) { 430 |
    483 |
    484 | 485 |

    { strings.ReplaceAll(strings.Split(data.name, "/")[1], "-", " ") }

    486 | 487 |
    488 | switch data.company { 489 | case "openai": 490 | @components.LogoGPT("size-7") 491 | case "anthropic": 492 | @components.LogoClaude("size-7") 493 | case "google": 494 | @components.LogoGemini("size-7") 495 | case "deepseek": 496 | @components.LogoDeepSeek("size-7") 497 | default: 498 | @components.LogoGPT("size-7") 499 | } 500 |
    501 | 502 |
    503 |
      504 | for _,badge := range data.badges { 505 |
    • 506 | @badge 507 |
    • 508 | } 509 |
    510 |
    511 |
    512 |
    513 | } 514 | 515 | //--Badge-- 516 | //TODO: Move to components 517 | type Color string 518 | 519 | const ( 520 | ColorPrimary Color = "primary" 521 | ColorSecondary Color = "secondary" 522 | ColorAccent Color = "accent" 523 | ColorNeutral Color = "neutral" 524 | ColorInfo Color = "info" 525 | ColorSuccess Color = "success" 526 | ColorWarning Color = "warning" 527 | ColorError Color = "error" 528 | ) 529 | 530 | type BadgeData struct { 531 | text string 532 | icon templ.Component 533 | color Color 534 | } 535 | 536 | templ Badge(data BadgeData) { 537 |
    540 | 541 |
    560 | @data.icon 561 |
    562 |
    563 | 564 | } 565 | //End --Badge-- 566 | 567 | templ Models() { 568 |

    Models

    569 | 573 | 590 | 591 | } 592 | 593 | 594 | templ ActionBar() { 595 |
    598 |
    599 | 602 |
    603 | @SlideCard(SlideCardData{ 604 | dir: "right", 605 | id: "settings", 606 | title: "Settings", 607 | icon: components.IconSettings("size-5"), 608 | content: Settings(), 609 | }) 610 | 611 | @SlideCard(SlideCardData{ 612 | dir: "right", 613 | id: "theme", 614 | title: "Theme", 615 | icon: components.IconPaintRoller("size-5"), 616 | content: Theme(), 617 | }) 618 | 619 | @SlideCard(SlideCardData{ 620 | dir: "right", 621 | id: "keybinds", 622 | title: "Keybinds", 623 | icon: components.IconKeyboard("size-5"), 624 | content: Keybinds(), 625 | }) 626 | 627 | @SlideCard(SlideCardData{ 628 | dir: "right", 629 | id: "models", 630 | title: "Models", 631 | icon: components.IconCPU("size-5"), 632 | content: Models(), 633 | }) 634 | 635 |
    636 | } 637 | 638 | templ PromptBar() { 639 |
    645 | 646 |
    647 | 648 | 651 |
    652 | 653 |
    654 | 655 | 658 |
    659 | 660 |
    661 | 662 | 665 |
    666 | 667 | 684 | 685 |
    686 | 695 |
    696 |
    697 | } 698 | -------------------------------------------------------------------------------- /pb_schema.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "pbc_3142635823", 4 | "listRule": null, 5 | "viewRule": null, 6 | "createRule": null, 7 | "updateRule": null, 8 | "deleteRule": null, 9 | "name": "_superusers", 10 | "type": "auth", 11 | "fields": [ 12 | { 13 | "autogeneratePattern": "[a-z0-9]{15}", 14 | "hidden": false, 15 | "id": "text3208210256", 16 | "max": 15, 17 | "min": 15, 18 | "name": "id", 19 | "pattern": "^[a-z0-9]+$", 20 | "presentable": false, 21 | "primaryKey": true, 22 | "required": true, 23 | "system": true, 24 | "type": "text" 25 | }, 26 | { 27 | "cost": 0, 28 | "hidden": true, 29 | "id": "password901924565", 30 | "max": 0, 31 | "min": 8, 32 | "name": "password", 33 | "pattern": "", 34 | "presentable": false, 35 | "required": true, 36 | "system": true, 37 | "type": "password" 38 | }, 39 | { 40 | "autogeneratePattern": "[a-zA-Z0-9]{50}", 41 | "hidden": true, 42 | "id": "text2504183744", 43 | "max": 60, 44 | "min": 30, 45 | "name": "tokenKey", 46 | "pattern": "", 47 | "presentable": false, 48 | "primaryKey": false, 49 | "required": true, 50 | "system": true, 51 | "type": "text" 52 | }, 53 | { 54 | "exceptDomains": null, 55 | "hidden": false, 56 | "id": "email3885137012", 57 | "name": "email", 58 | "onlyDomains": null, 59 | "presentable": false, 60 | "required": true, 61 | "system": true, 62 | "type": "email" 63 | }, 64 | { 65 | "hidden": false, 66 | "id": "bool1547992806", 67 | "name": "emailVisibility", 68 | "presentable": false, 69 | "required": false, 70 | "system": true, 71 | "type": "bool" 72 | }, 73 | { 74 | "hidden": false, 75 | "id": "bool256245529", 76 | "name": "verified", 77 | "presentable": false, 78 | "required": false, 79 | "system": true, 80 | "type": "bool" 81 | }, 82 | { 83 | "hidden": false, 84 | "id": "autodate2990389176", 85 | "name": "created", 86 | "onCreate": true, 87 | "onUpdate": false, 88 | "presentable": false, 89 | "system": true, 90 | "type": "autodate" 91 | }, 92 | { 93 | "hidden": false, 94 | "id": "autodate3332085495", 95 | "name": "updated", 96 | "onCreate": true, 97 | "onUpdate": true, 98 | "presentable": false, 99 | "system": true, 100 | "type": "autodate" 101 | } 102 | ], 103 | "indexes": [ 104 | "CREATE UNIQUE INDEX `idx_tokenKey_pbc_3142635823` ON `_superusers` (`tokenKey`)", 105 | "CREATE UNIQUE INDEX `idx_email_pbc_3142635823` ON `_superusers` (`email`) WHERE `email` != ''" 106 | ], 107 | "system": true, 108 | "authRule": "", 109 | "manageRule": null, 110 | "authAlert": { 111 | "enabled": true, 112 | "emailTemplate": { 113 | "subject": "Login from a new location", 114 | "body": "

    Hello,

    \n

    We noticed a login to your {APP_NAME} account from a new location.

    \n

    If this was you, you may disregard this email.

    \n

    If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 115 | } 116 | }, 117 | "oauth2": { 118 | "mappedFields": { 119 | "id": "", 120 | "name": "", 121 | "username": "", 122 | "avatarURL": "" 123 | }, 124 | "enabled": false 125 | }, 126 | "passwordAuth": { 127 | "enabled": true, 128 | "identityFields": [ 129 | "email" 130 | ] 131 | }, 132 | "mfa": { 133 | "enabled": false, 134 | "duration": 1800, 135 | "rule": "" 136 | }, 137 | "otp": { 138 | "enabled": false, 139 | "duration": 180, 140 | "length": 8, 141 | "emailTemplate": { 142 | "subject": "OTP for {APP_NAME}", 143 | "body": "

    Hello,

    \n

    Your one-time password is: {OTP}

    \n

    If you didn't ask for the one-time password, you can ignore this email.

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 144 | } 145 | }, 146 | "authToken": { 147 | "duration": 86400 148 | }, 149 | "passwordResetToken": { 150 | "duration": 1800 151 | }, 152 | "emailChangeToken": { 153 | "duration": 1800 154 | }, 155 | "verificationToken": { 156 | "duration": 259200 157 | }, 158 | "fileToken": { 159 | "duration": 180 160 | }, 161 | "verificationTemplate": { 162 | "subject": "Verify your {APP_NAME} email", 163 | "body": "

    Hello,

    \n

    Thank you for joining us at {APP_NAME}.

    \n

    Click on the button below to verify your email address.

    \n

    \n Verify\n

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 164 | }, 165 | "resetPasswordTemplate": { 166 | "subject": "Reset your {APP_NAME} password", 167 | "body": "

    Hello,

    \n

    Click on the button below to reset your password.

    \n

    \n Reset password\n

    \n

    If you didn't ask to reset your password, you can ignore this email.

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 168 | }, 169 | "confirmEmailChangeTemplate": { 170 | "subject": "Confirm your {APP_NAME} new email address", 171 | "body": "

    Hello,

    \n

    Click on the button below to confirm your new email address.

    \n

    \n Confirm new email\n

    \n

    If you didn't ask to change your email address, you can ignore this email.

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 172 | } 173 | }, 174 | { 175 | "id": "_pb_users_auth_", 176 | "listRule": "id = @request.auth.id", 177 | "viewRule": "id = @request.auth.id", 178 | "createRule": "", 179 | "updateRule": "id = @request.auth.id", 180 | "deleteRule": "id = @request.auth.id", 181 | "name": "users", 182 | "type": "auth", 183 | "fields": [ 184 | { 185 | "autogeneratePattern": "[a-z0-9]{15}", 186 | "hidden": false, 187 | "id": "text3208210256", 188 | "max": 15, 189 | "min": 15, 190 | "name": "id", 191 | "pattern": "^[a-z0-9]+$", 192 | "presentable": false, 193 | "primaryKey": true, 194 | "required": true, 195 | "system": true, 196 | "type": "text" 197 | }, 198 | { 199 | "cost": 0, 200 | "hidden": true, 201 | "id": "password901924565", 202 | "max": 0, 203 | "min": 8, 204 | "name": "password", 205 | "pattern": "", 206 | "presentable": false, 207 | "required": true, 208 | "system": true, 209 | "type": "password" 210 | }, 211 | { 212 | "autogeneratePattern": "[a-zA-Z0-9]{50}", 213 | "hidden": true, 214 | "id": "text2504183744", 215 | "max": 60, 216 | "min": 30, 217 | "name": "tokenKey", 218 | "pattern": "", 219 | "presentable": false, 220 | "primaryKey": false, 221 | "required": true, 222 | "system": true, 223 | "type": "text" 224 | }, 225 | { 226 | "exceptDomains": null, 227 | "hidden": false, 228 | "id": "email3885137012", 229 | "name": "email", 230 | "onlyDomains": null, 231 | "presentable": false, 232 | "required": true, 233 | "system": true, 234 | "type": "email" 235 | }, 236 | { 237 | "hidden": false, 238 | "id": "bool1547992806", 239 | "name": "emailVisibility", 240 | "presentable": false, 241 | "required": false, 242 | "system": true, 243 | "type": "bool" 244 | }, 245 | { 246 | "hidden": false, 247 | "id": "bool256245529", 248 | "name": "verified", 249 | "presentable": false, 250 | "required": false, 251 | "system": true, 252 | "type": "bool" 253 | }, 254 | { 255 | "autogeneratePattern": "", 256 | "hidden": false, 257 | "id": "text1579384326", 258 | "max": 255, 259 | "min": 0, 260 | "name": "name", 261 | "pattern": "", 262 | "presentable": false, 263 | "primaryKey": false, 264 | "required": false, 265 | "system": false, 266 | "type": "text" 267 | }, 268 | { 269 | "hidden": false, 270 | "id": "file376926767", 271 | "maxSelect": 1, 272 | "maxSize": 0, 273 | "mimeTypes": [ 274 | "image/jpeg", 275 | "image/png", 276 | "image/svg+xml", 277 | "image/gif", 278 | "image/webp" 279 | ], 280 | "name": "avatar", 281 | "presentable": false, 282 | "protected": false, 283 | "required": false, 284 | "system": false, 285 | "thumbs": null, 286 | "type": "file" 287 | }, 288 | { 289 | "hidden": false, 290 | "id": "autodate2990389176", 291 | "name": "created", 292 | "onCreate": true, 293 | "onUpdate": false, 294 | "presentable": false, 295 | "system": false, 296 | "type": "autodate" 297 | }, 298 | { 299 | "hidden": false, 300 | "id": "autodate3332085495", 301 | "name": "updated", 302 | "onCreate": true, 303 | "onUpdate": true, 304 | "presentable": false, 305 | "system": false, 306 | "type": "autodate" 307 | } 308 | ], 309 | "indexes": [ 310 | "CREATE UNIQUE INDEX `idx_tokenKey__pb_users_auth_` ON `users` (`tokenKey`)", 311 | "CREATE UNIQUE INDEX `idx_email__pb_users_auth_` ON `users` (`email`) WHERE `email` != ''" 312 | ], 313 | "system": false, 314 | "authRule": "", 315 | "manageRule": null, 316 | "authAlert": { 317 | "enabled": true, 318 | "emailTemplate": { 319 | "subject": "Login from a new location", 320 | "body": "

    Hello,

    \n

    We noticed a login to your {APP_NAME} account from a new location.

    \n

    If this was you, you may disregard this email.

    \n

    If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 321 | } 322 | }, 323 | "oauth2": { 324 | "mappedFields": { 325 | "id": "", 326 | "name": "name", 327 | "username": "", 328 | "avatarURL": "avatar" 329 | }, 330 | "enabled": true 331 | }, 332 | "passwordAuth": { 333 | "enabled": true, 334 | "identityFields": [ 335 | "email" 336 | ] 337 | }, 338 | "mfa": { 339 | "enabled": false, 340 | "duration": 1800, 341 | "rule": "" 342 | }, 343 | "otp": { 344 | "enabled": false, 345 | "duration": 180, 346 | "length": 8, 347 | "emailTemplate": { 348 | "subject": "OTP for {APP_NAME}", 349 | "body": "

    Hello,

    \n

    Your one-time password is: {OTP}

    \n

    If you didn't ask for the one-time password, you can ignore this email.

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 350 | } 351 | }, 352 | "authToken": { 353 | "duration": 604800 354 | }, 355 | "passwordResetToken": { 356 | "duration": 1800 357 | }, 358 | "emailChangeToken": { 359 | "duration": 1800 360 | }, 361 | "verificationToken": { 362 | "duration": 259200 363 | }, 364 | "fileToken": { 365 | "duration": 180 366 | }, 367 | "verificationTemplate": { 368 | "subject": "Verify your {APP_NAME} email", 369 | "body": "

    Hello,

    \n

    Thank you for joining us at {APP_NAME}.

    \n

    Click on the button below to verify your email address.

    \n

    \n Verify\n

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 370 | }, 371 | "resetPasswordTemplate": { 372 | "subject": "Reset your {APP_NAME} password", 373 | "body": "

    Hello,

    \n

    Click on the button below to reset your password.

    \n

    \n Reset password\n

    \n

    If you didn't ask to reset your password, you can ignore this email.

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 374 | }, 375 | "confirmEmailChangeTemplate": { 376 | "subject": "Confirm your {APP_NAME} new email address", 377 | "body": "

    Hello,

    \n

    Click on the button below to confirm your new email address.

    \n

    \n Confirm new email\n

    \n

    If you didn't ask to change your email address, you can ignore this email.

    \n

    \n Thanks,
    \n {APP_NAME} team\n

    " 378 | } 379 | }, 380 | { 381 | "id": "pbc_4275539003", 382 | "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 383 | "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 384 | "createRule": null, 385 | "updateRule": null, 386 | "deleteRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 387 | "name": "_authOrigins", 388 | "type": "base", 389 | "fields": [ 390 | { 391 | "autogeneratePattern": "[a-z0-9]{15}", 392 | "hidden": false, 393 | "id": "text3208210256", 394 | "max": 15, 395 | "min": 15, 396 | "name": "id", 397 | "pattern": "^[a-z0-9]+$", 398 | "presentable": false, 399 | "primaryKey": true, 400 | "required": true, 401 | "system": true, 402 | "type": "text" 403 | }, 404 | { 405 | "autogeneratePattern": "", 406 | "hidden": false, 407 | "id": "text455797646", 408 | "max": 0, 409 | "min": 0, 410 | "name": "collectionRef", 411 | "pattern": "", 412 | "presentable": false, 413 | "primaryKey": false, 414 | "required": true, 415 | "system": true, 416 | "type": "text" 417 | }, 418 | { 419 | "autogeneratePattern": "", 420 | "hidden": false, 421 | "id": "text127846527", 422 | "max": 0, 423 | "min": 0, 424 | "name": "recordRef", 425 | "pattern": "", 426 | "presentable": false, 427 | "primaryKey": false, 428 | "required": true, 429 | "system": true, 430 | "type": "text" 431 | }, 432 | { 433 | "autogeneratePattern": "", 434 | "hidden": false, 435 | "id": "text4228609354", 436 | "max": 0, 437 | "min": 0, 438 | "name": "fingerprint", 439 | "pattern": "", 440 | "presentable": false, 441 | "primaryKey": false, 442 | "required": true, 443 | "system": true, 444 | "type": "text" 445 | }, 446 | { 447 | "hidden": false, 448 | "id": "autodate2990389176", 449 | "name": "created", 450 | "onCreate": true, 451 | "onUpdate": false, 452 | "presentable": false, 453 | "system": true, 454 | "type": "autodate" 455 | }, 456 | { 457 | "hidden": false, 458 | "id": "autodate3332085495", 459 | "name": "updated", 460 | "onCreate": true, 461 | "onUpdate": true, 462 | "presentable": false, 463 | "system": true, 464 | "type": "autodate" 465 | } 466 | ], 467 | "indexes": [ 468 | "CREATE UNIQUE INDEX `idx_authOrigins_unique_pairs` ON `_authOrigins` (collectionRef, recordRef, fingerprint)" 469 | ], 470 | "system": true 471 | }, 472 | { 473 | "id": "pbc_2281828961", 474 | "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 475 | "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 476 | "createRule": null, 477 | "updateRule": null, 478 | "deleteRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 479 | "name": "_externalAuths", 480 | "type": "base", 481 | "fields": [ 482 | { 483 | "autogeneratePattern": "[a-z0-9]{15}", 484 | "hidden": false, 485 | "id": "text3208210256", 486 | "max": 15, 487 | "min": 15, 488 | "name": "id", 489 | "pattern": "^[a-z0-9]+$", 490 | "presentable": false, 491 | "primaryKey": true, 492 | "required": true, 493 | "system": true, 494 | "type": "text" 495 | }, 496 | { 497 | "autogeneratePattern": "", 498 | "hidden": false, 499 | "id": "text455797646", 500 | "max": 0, 501 | "min": 0, 502 | "name": "collectionRef", 503 | "pattern": "", 504 | "presentable": false, 505 | "primaryKey": false, 506 | "required": true, 507 | "system": true, 508 | "type": "text" 509 | }, 510 | { 511 | "autogeneratePattern": "", 512 | "hidden": false, 513 | "id": "text127846527", 514 | "max": 0, 515 | "min": 0, 516 | "name": "recordRef", 517 | "pattern": "", 518 | "presentable": false, 519 | "primaryKey": false, 520 | "required": true, 521 | "system": true, 522 | "type": "text" 523 | }, 524 | { 525 | "autogeneratePattern": "", 526 | "hidden": false, 527 | "id": "text2462348188", 528 | "max": 0, 529 | "min": 0, 530 | "name": "provider", 531 | "pattern": "", 532 | "presentable": false, 533 | "primaryKey": false, 534 | "required": true, 535 | "system": true, 536 | "type": "text" 537 | }, 538 | { 539 | "autogeneratePattern": "", 540 | "hidden": false, 541 | "id": "text1044722854", 542 | "max": 0, 543 | "min": 0, 544 | "name": "providerId", 545 | "pattern": "", 546 | "presentable": false, 547 | "primaryKey": false, 548 | "required": true, 549 | "system": true, 550 | "type": "text" 551 | }, 552 | { 553 | "hidden": false, 554 | "id": "autodate2990389176", 555 | "name": "created", 556 | "onCreate": true, 557 | "onUpdate": false, 558 | "presentable": false, 559 | "system": true, 560 | "type": "autodate" 561 | }, 562 | { 563 | "hidden": false, 564 | "id": "autodate3332085495", 565 | "name": "updated", 566 | "onCreate": true, 567 | "onUpdate": true, 568 | "presentable": false, 569 | "system": true, 570 | "type": "autodate" 571 | } 572 | ], 573 | "indexes": [ 574 | "CREATE UNIQUE INDEX `idx_externalAuths_record_provider` ON `_externalAuths` (collectionRef, recordRef, provider)", 575 | "CREATE UNIQUE INDEX `idx_externalAuths_collection_provider` ON `_externalAuths` (collectionRef, provider, providerId)" 576 | ], 577 | "system": true 578 | }, 579 | { 580 | "id": "pbc_2279338944", 581 | "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 582 | "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 583 | "createRule": null, 584 | "updateRule": null, 585 | "deleteRule": null, 586 | "name": "_mfas", 587 | "type": "base", 588 | "fields": [ 589 | { 590 | "autogeneratePattern": "[a-z0-9]{15}", 591 | "hidden": false, 592 | "id": "text3208210256", 593 | "max": 15, 594 | "min": 15, 595 | "name": "id", 596 | "pattern": "^[a-z0-9]+$", 597 | "presentable": false, 598 | "primaryKey": true, 599 | "required": true, 600 | "system": true, 601 | "type": "text" 602 | }, 603 | { 604 | "autogeneratePattern": "", 605 | "hidden": false, 606 | "id": "text455797646", 607 | "max": 0, 608 | "min": 0, 609 | "name": "collectionRef", 610 | "pattern": "", 611 | "presentable": false, 612 | "primaryKey": false, 613 | "required": true, 614 | "system": true, 615 | "type": "text" 616 | }, 617 | { 618 | "autogeneratePattern": "", 619 | "hidden": false, 620 | "id": "text127846527", 621 | "max": 0, 622 | "min": 0, 623 | "name": "recordRef", 624 | "pattern": "", 625 | "presentable": false, 626 | "primaryKey": false, 627 | "required": true, 628 | "system": true, 629 | "type": "text" 630 | }, 631 | { 632 | "autogeneratePattern": "", 633 | "hidden": false, 634 | "id": "text1582905952", 635 | "max": 0, 636 | "min": 0, 637 | "name": "method", 638 | "pattern": "", 639 | "presentable": false, 640 | "primaryKey": false, 641 | "required": true, 642 | "system": true, 643 | "type": "text" 644 | }, 645 | { 646 | "hidden": false, 647 | "id": "autodate2990389176", 648 | "name": "created", 649 | "onCreate": true, 650 | "onUpdate": false, 651 | "presentable": false, 652 | "system": true, 653 | "type": "autodate" 654 | }, 655 | { 656 | "hidden": false, 657 | "id": "autodate3332085495", 658 | "name": "updated", 659 | "onCreate": true, 660 | "onUpdate": true, 661 | "presentable": false, 662 | "system": true, 663 | "type": "autodate" 664 | } 665 | ], 666 | "indexes": [ 667 | "CREATE INDEX `idx_mfas_collectionRef_recordRef` ON `_mfas` (collectionRef,recordRef)" 668 | ], 669 | "system": true 670 | }, 671 | { 672 | "id": "pbc_1638494021", 673 | "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 674 | "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", 675 | "createRule": null, 676 | "updateRule": null, 677 | "deleteRule": null, 678 | "name": "_otps", 679 | "type": "base", 680 | "fields": [ 681 | { 682 | "autogeneratePattern": "[a-z0-9]{15}", 683 | "hidden": false, 684 | "id": "text3208210256", 685 | "max": 15, 686 | "min": 15, 687 | "name": "id", 688 | "pattern": "^[a-z0-9]+$", 689 | "presentable": false, 690 | "primaryKey": true, 691 | "required": true, 692 | "system": true, 693 | "type": "text" 694 | }, 695 | { 696 | "autogeneratePattern": "", 697 | "hidden": false, 698 | "id": "text455797646", 699 | "max": 0, 700 | "min": 0, 701 | "name": "collectionRef", 702 | "pattern": "", 703 | "presentable": false, 704 | "primaryKey": false, 705 | "required": true, 706 | "system": true, 707 | "type": "text" 708 | }, 709 | { 710 | "autogeneratePattern": "", 711 | "hidden": false, 712 | "id": "text127846527", 713 | "max": 0, 714 | "min": 0, 715 | "name": "recordRef", 716 | "pattern": "", 717 | "presentable": false, 718 | "primaryKey": false, 719 | "required": true, 720 | "system": true, 721 | "type": "text" 722 | }, 723 | { 724 | "cost": 8, 725 | "hidden": true, 726 | "id": "password901924565", 727 | "max": 0, 728 | "min": 0, 729 | "name": "password", 730 | "pattern": "", 731 | "presentable": false, 732 | "required": true, 733 | "system": true, 734 | "type": "password" 735 | }, 736 | { 737 | "autogeneratePattern": "", 738 | "hidden": true, 739 | "id": "text3866985172", 740 | "max": 0, 741 | "min": 0, 742 | "name": "sentTo", 743 | "pattern": "", 744 | "presentable": false, 745 | "primaryKey": false, 746 | "required": false, 747 | "system": true, 748 | "type": "text" 749 | }, 750 | { 751 | "hidden": false, 752 | "id": "autodate2990389176", 753 | "name": "created", 754 | "onCreate": true, 755 | "onUpdate": false, 756 | "presentable": false, 757 | "system": true, 758 | "type": "autodate" 759 | }, 760 | { 761 | "hidden": false, 762 | "id": "autodate3332085495", 763 | "name": "updated", 764 | "onCreate": true, 765 | "onUpdate": true, 766 | "presentable": false, 767 | "system": true, 768 | "type": "autodate" 769 | } 770 | ], 771 | "indexes": [ 772 | "CREATE INDEX `idx_otps_collectionRef_recordRef` ON `_otps` (collectionRef, recordRef)" 773 | ], 774 | "system": true 775 | }, 776 | { 777 | "id": "pbc_3552922951", 778 | "listRule": "", 779 | "viewRule": null, 780 | "createRule": null, 781 | "updateRule": null, 782 | "deleteRule": null, 783 | "name": "models", 784 | "type": "base", 785 | "fields": [ 786 | { 787 | "autogeneratePattern": "[a-z0-9]{15}", 788 | "hidden": false, 789 | "id": "text3208210256", 790 | "max": 15, 791 | "min": 15, 792 | "name": "id", 793 | "pattern": "^[a-z0-9]+$", 794 | "presentable": false, 795 | "primaryKey": true, 796 | "required": true, 797 | "system": true, 798 | "type": "text" 799 | }, 800 | { 801 | "autogeneratePattern": "", 802 | "hidden": false, 803 | "id": "text1579384326", 804 | "max": 0, 805 | "min": 0, 806 | "name": "name", 807 | "pattern": "", 808 | "presentable": false, 809 | "primaryKey": false, 810 | "required": false, 811 | "system": false, 812 | "type": "text" 813 | }, 814 | { 815 | "hidden": false, 816 | "id": "autodate2990389176", 817 | "name": "created", 818 | "onCreate": true, 819 | "onUpdate": false, 820 | "presentable": false, 821 | "system": false, 822 | "type": "autodate" 823 | }, 824 | { 825 | "hidden": false, 826 | "id": "autodate3332085495", 827 | "name": "updated", 828 | "onCreate": true, 829 | "onUpdate": true, 830 | "presentable": false, 831 | "system": false, 832 | "type": "autodate" 833 | } 834 | ], 835 | "indexes": [ 836 | "CREATE UNIQUE INDEX `idx_33TuWqay3q` ON `models` (`name`)" 837 | ], 838 | "system": false 839 | } 840 | ] --------------------------------------------------------------------------------