Add a basic main page with login

None of the links work and the marketing copy sucks, but it's just
showing the bones while I figure the technical bits out.
This commit is contained in:
Eli Ribble 2025-11-03 22:13:11 +00:00
parent 25039a8f54
commit 56eaa4ed1c
No known key found for this signature in database
10 changed files with 266 additions and 3 deletions

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
static/img filter=lfs diff=lfs merge=lfs -text
static/vendor/css filter=lfs diff=lfs merge=lfs -text
static/vendor/js filter=lfs diff=lfs merge=lfs -text

116
fileserver.go Normal file
View file

@ -0,0 +1,116 @@
package main
import (
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
//go:embed static
var embeddedStaticFS embed.FS
// FileServer conveniently sets up a http.FileServer handler to serve
// static files from a http.FileSystem.
func FileServer(r chi.Router, path string, root http.FileSystem, embeddedFS embed.FS, embeddedPath string) {
if strings.ContainsAny(path, "{}*") {
panic("FileServer does not permit any URL parameters.")
}
if path != "/" && path[len(path)-1] != '/' {
r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP)
path += "/"
}
path += "*"
r.Get(path, func(w http.ResponseWriter, r *http.Request) {
rctx := chi.RouteContext(r.Context())
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
// Determine the actual file path
requestedPath := strings.TrimPrefix(r.URL.Path, pathPrefix)
// Try to open from local filesystem first for development
localFile, localErr := root.Open(requestedPath)
var fileToServe http.File
if localErr == nil {
// File found in local filesystem
fileToServe = localFile
} else {
// If not found locall, try embedded filesystem
embeddedFilePath := filepath.Join(embeddedPath, requestedPath)
embeddedFile, err := embeddedFS.Open(embeddedFilePath)
if err != nil {
http.NotFound(w, r)
return
}
// Wrap the embedded file to implement http.File interface
fileToServe = &embeddedFileWrapper{embeddedFile}
}
// Create a custom ResponseWriter that allows us to modify headers
crw := &customResponseWriter{ResponseWriter: w}
// Serve the file
http.ServeContent(crw, r, requestedPath, time.Time{}, fileToServe)
// Close the file
fileToServe.Close()
})
}
type embeddedFileWrapper struct {
file fs.File
}
func (e *embeddedFileWrapper) Close() error {
return e.file.Close()
}
func (e *embeddedFileWrapper) Read(p []byte) (n int, err error) {
return e.file.Read(p)
}
type Seeker interface {
Seek(offset int64, whence int) (int64, error)
}
func (e *embeddedFileWrapper) Seek(offset int64, whence int) (int64, error) {
if seeker, ok := e.file.(Seeker); ok {
return seeker.Seek(offset, whence)
}
return 0, fmt.Errorf("Seek not supported")
}
func (e *embeddedFileWrapper) Readdir(count int) ([]os.FileInfo, error) {
// This is a bit tricky with embedded files
if dirFile, ok := e.file.(fs.ReadDirFile); ok {
entries, err := dirFile.ReadDir(count)
if err != nil {
return nil, err
}
fileInfos := make([]os.FileInfo, len(entries))
for i, entry := range entries {
fileInfos[i], err = entry.Info()
if err != nil {
return nil, err
}
}
return fileInfos, nil
}
return nil, fmt.Errorf("Readdir not supported")
}
func (e *embeddedFileWrapper) Stat() (os.FileInfo, error) {
return e.file.Stat()
}

View file

@ -46,6 +46,10 @@ func main() {
r.Get("/", getRoot)
r.Get("/favicon.ico", getFavicon)
localFS := http.Dir("./static")
FileServer(r, "/static", localFS, embeddedStaticFS, "static")
log.Printf("Serving on %s", bind)
log.Fatal(http.ListenAndServe(bind, r))
}

31
response.go Normal file
View file

@ -0,0 +1,31 @@
package main
import (
"net/http"
)
// Custom ResponseWriter to track Content-Type
type customResponseWriter struct {
http.ResponseWriter
contentType string
wroteHeader bool
}
func (crw *customResponseWriter) WriteHeader(code int) {
crw.wroteHeader = true
crw.ResponseWriter.WriteHeader(code)
}
func (crw *customResponseWriter) Header() http.Header {
return crw.ResponseWriter.Header()
}
func (crw *customResponseWriter) Write(b []byte) (int, error) {
if !crw.wroteHeader {
if crw.contentType == "" {
crw.contentType = http.DetectContentType(b)
crw.ResponseWriter.Header().Set("Content-Type", crw.contentType)
}
crw.WriteHeader(http.StatusOK)
}
return crw.ResponseWriter.Write(b)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

7
static/vendor/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
static/vendor/js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -2,9 +2,15 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>Nidus Sync</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{template "title" .}} - Nidus Sync</title>
<link href="/static/vendor/css/bootstrap.min.css" rel="stylesheet">
<style>
{{template "style" .}}
</style>
</head>
<body>
{{template "content" .}}
<script src="/static/vendor/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View file

@ -1,5 +1,87 @@
{{template "base.html" .}}
{{define "content"}}
<p>Imagine there's cool stuff here.</p>
{{define "title"}}Login{{end}}
{{define "style"}}
.login-container {
max-width: 900px;
margin: 0 auto;
}
.login-box {
box-shadow: 0 0 15px rgba(0,0,0,0.1);
border-radius: 10px;
overflow: hidden;
}
.login-form-section {
padding: 40px;
}
.product-info-section {
padding: 40px;
background-color: #f8f9fa;
}
.login-header {
margin-bottom: 25px;
}
{{end}}
{{define "content"}}
<div class="container min-vh-100 d-flex align-items-center justify-content-center py-5">
<div class="login-container">
<div class="row login-box g-0">
<!-- Left side: Login Form -->
<div class="col-md-6 login-form-section">
<div class="login-header">
<h2>Welcome Back</h2>
<p class="text-muted">Please enter your credentials</p>
</div>
<form>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember">
<label class="form-check-label" for="remember">Remember me</label>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Login</button>
</div>
<div class="mt-3 text-center">
<p>Don't have an account? <a href="/signup">Sign up</a></p>
<a href="forgot-password.html">Forgot password?</a>
</div>
</form>
</div>
<!-- Right side: Product Information -->
<div class="col-md-6 product-info-section">
<div>
<img src="/static/img/nidus-logo-256-transparent.png"></img>
<h2>Nidus Sync</h2>
<p class="lead mb-4">All your field data, sync'd to all your techs</p>
<div class="mb-4">
<p>Something intelligent and intriguing</p>
</div>
<div class="mb-4">
<h5>Key Features</h5>
<ul>
<li>Works with <b>Fieldseeker</b></li>
<li>Works <i>with</i> Fieldseeker</li>
<li><b>Works</b> with Fieldseeker</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}