diff --git a/.gitignore b/.gitignore index 7d9d44f..5033616 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,10 @@ -# Nim compiler cache -nimcache/ -nimcache_*/ -nimcache.* - -# Nimble package manager cache and build metadata -nimblecache/ -nimbledeps/ -*.nimbledir/ -*.nimblecache -*.nims.lock -*.nimsbak - # Build outputs bin/ build/ -htmldocs/ -jsondocs/ + +# Go tool artifacts +*.test +coverage.out # Compiled binaries and objects *.exe @@ -44,5 +33,4 @@ jsondocs/ Thumbs.db .idea/ .vscode/ -project.nimcache/ diff --git a/README.md b/README.md index 71c49c6..3d85223 100755 --- a/README.md +++ b/README.md @@ -1,56 +1,50 @@ -# Luxtools Web Application Scaffold +# Luxtools (Go edition) -Luxtools is a minimal-but-complete scaffold for a retro-styled web application built with the Nim programming language. It demonstrates how to deliver an interactive HTMX front end backed by a Nim HTTP server that compiles down to a single distributable binary. - -## Why these choices? - -- **Nim**: Nim's ahead-of-time compilation and small standard library make it ideal for producing a single self-contained binary. Its async HTTP primitives provide great performance with minimal boilerplate. -- **HTMX**: HTMX keeps the frontend simple by embracing hypermedia. We can ship almost entirely server-rendered HTML snippets, allowing Nim to stay in control without a bulky SPA build step. -- **TUI.CSS**: This lightweight CSS framework offers a retro terminal aesthetic with zero JavaScript and a tiny footprint—perfect for a nostalgic interface while remaining easy to customize. -- **Single binary delivery**: HTML templates and the entire `src/static/` tree of vendored assets are embedded at compile time via `staticRead`, so the server ships with everything it needs. The compiled binary serves those resources directly from an in-memory table, while a separate `assets/` directory remains available for user-provided uploads. +Luxtools is now implemented entirely in Go. The project serves a retro-styled HTMX interface backed by a lightweight Go HTTP server that mirrors the behaviour of the former Nim version. ## Project layout ``` luxtools/ -├── bin/ # Output directory for release binaries -├── src/ -│ ├── luxtools.nim # Main server entrypoint -│ ├── static/ # Vendored assets compiled into the binary static table -│ └── templates/ # HTMX-powered landing page (compiled into the binary) -├── assets/ # Runtime asset directory served from disk -├── tests/ -│ └── test_rendering.nim# Lightweight regression tests for HTML helpers -├── luxtools.nimble # Nimble manifest with build/test tasks +├── assets/ # Runtime asset directory served from disk (/assets/*) +├── cmd/luxtools/ # Main program entrypoint +├── internal/server/ # HTTP server, handlers, helpers, and tests +├── web/ +│ ├── static/ # Vendored front-end assets (HTMX, TUI.CSS, images) +│ └── templates/ # HTML templates rendered by the server +├── deploy/ # Deployment manifests (systemd unit, etc.) +├── go.mod # Go module definition └── README.md # You're here ``` -## Running the app +## Prerequisites -```text -nimble run +- Go 1.22 or newer + +## Run the server + +```bash +go run ./cmd/luxtools ``` -This command compiles the server (in debug mode) and starts it at http://127.0.0.1:5000. The console output confirms the address. Open the URL in a browser to see the TUI.CSS interface. The counter widget uses `hx-post` to mutate state on the server, and the clock panel uses `hx-get` with a timed trigger. +The server listens on [http://127.0.0.1:5000](http://127.0.0.1:5000) and serves: -## Building the single binary - -```text -nimble build -``` - -A release-optimized executable is emitted to `bin/luxtools` (or `bin/luxtools.exe` on Windows). Because the HTML is embedded at compile time, shipping this one file is enough. +- `/` — main page rendered from `web/templates/index.html` +- `/counter` — HTMX-powered counter snippet with server-side state +- `/time` — current server timestamp +- `/static/*` — vendored assets from `web/static` +- `/assets/*` — runtime assets from the shared `assets` directory ## Testing -```text -nimble test +```bash +go test ./... ``` -The test suite checks the generated HTMX markup to catch regressions in the HTML helpers without spinning up the HTTP server. +## Building for deployment -## Next steps (optional) +```bash +go build -o bin/luxtools ./cmd/luxtools +``` -- Add persistence by wiring Nim's database libraries or a lightweight KV store. -- Serve additional HTMX endpoints (e.g., todo lists, metrics dashboards). -- Extend the styling by composing more TUI.CSS components or creating custom themes. +The resulting binary is compatible with the provided systemd unit located in `deploy/luxtools.service`. diff --git a/cmd/luxtools/main.go b/cmd/luxtools/main.go new file mode 100644 index 0000000..2141df8 --- /dev/null +++ b/cmd/luxtools/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "luxtools/internal/server" +) + +func main() { + srv, err := server.New() + if err != nil { + log.Fatalf("failed to configure server: %v", err) + } + + httpServer := &http.Server{ + Addr: srv.Address(), + Handler: srv.Router(), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + log.Printf("Luxtools web server running on http://127.0.0.1%s", srv.Address()) + + go func() { + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Printf("graceful shutdown failed: %v", err) + } else { + log.Println("server stopped") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..339ad76 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module luxtools + +go 1.22.0 diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..1fd4c78 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,170 @@ +package server + +import ( + "errors" + "fmt" + "html" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "sync" + "time" +) + +const ( + address = ":5000" + htmlContentType = "text/html; charset=utf-8" + notFoundTemplate = ` +
+
+ Error +

Route %s not found.

+
+
+ ` +) + +// Server hosts the Luxtools HTTP handlers. +type Server struct { + mux *http.ServeMux + indexPage []byte + staticRoot string + assetsRoot string + + counterMu sync.Mutex + clickCount int +} + +// New constructs a configured Server ready to serve requests. +func New() (*Server, error) { + projectRoot, err := goProjectRoot() + if err != nil { + return nil, err + } + + indexPath := filepath.Join(projectRoot, "web", "templates", "index.html") + staticDir := filepath.Join(projectRoot, "web", "static") + assetsDir := filepath.Join(projectRoot, "assets") + + indexPage, err := os.ReadFile(indexPath) + if err != nil { + return nil, fmt.Errorf("failed to read index template: %w", err) + } + + if _, err := os.Stat(staticDir); err != nil { + return nil, fmt.Errorf("static directory check failed: %w", err) + } + if _, err := os.Stat(assetsDir); err != nil { + return nil, fmt.Errorf("assets directory check failed: %w", err) + } + + s := &Server{ + mux: http.NewServeMux(), + indexPage: indexPage, + staticRoot: staticDir, + assetsRoot: assetsDir, + } + + s.registerRoutes() + return s, nil +} + +// Router exposes the configured http.Handler for the server. +func (s *Server) Router() http.Handler { + return s.mux +} + +// Address returns the default listen address for the server. +func (s *Server) Address() string { + return address +} + +func (s *Server) registerRoutes() { + s.mux.Handle("/static/", http.StripPrefix("/static/", allowGetHead(http.FileServer(http.Dir(s.staticRoot))))) + s.mux.Handle("/assets/", http.StripPrefix("/assets/", allowGetHead(http.FileServer(http.Dir(s.assetsRoot))))) + s.mux.HandleFunc("/", s.handleRequest) +} + +func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + s.sendHTML(w, http.StatusOK, s.indexPage) + case "/counter": + s.handleCounter(w, r) + case "/time": + s.sendHTMLString(w, http.StatusOK, renderTime(time.Now())) + default: + s.sendHTMLString(w, http.StatusNotFound, fmt.Sprintf(notFoundTemplate, html.EscapeString(r.URL.Path))) + } +} + +func (s *Server) handleCounter(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost || r.Method == http.MethodPut { + s.counterMu.Lock() + s.clickCount++ + s.counterMu.Unlock() + } + + s.counterMu.Lock() + count := s.clickCount + s.counterMu.Unlock() + + s.sendHTMLString(w, http.StatusOK, renderCounter(count)) +} + +func (s *Server) sendHTML(w http.ResponseWriter, status int, body []byte) { + w.Header().Set("Content-Type", htmlContentType) + w.WriteHeader(status) + if len(body) > 0 { + if _, err := w.Write(body); err != nil { + log.Printf("write response failed: %v", err) + } + } +} + +func (s *Server) sendHTMLString(w http.ResponseWriter, status int, body string) { + s.sendHTML(w, status, []byte(body)) +} + +func renderCounter(count int) string { + return fmt.Sprintf(` +
+

Clicks: %d

+ +
+ `, count) +} + +func renderTime(now time.Time) string { + return fmt.Sprintf(` +
+

Server time: %s

+
+ `, now.Format("2006-01-02 15:04:05")) +} + +func allowGetHead(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead: + next.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +func goProjectRoot() (string, error) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + return "", errors.New("unable to determine caller information") + } + + dir := filepath.Dir(currentFile) + projectRoot := filepath.Clean(filepath.Join(dir, "..", "..")) + return projectRoot, nil +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..13020b5 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,68 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestRenderCounter(t *testing.T) { + html := renderCounter(7) + if !strings.Contains(html, "Clicks:") { + t.Fatalf("expected counter markup to include label") + } + if !strings.Contains(html, "> 7<") { + t.Fatalf("expected counter value in markup, got %q", html) + } +} + +func TestRenderTime(t *testing.T) { + now := time.Date(2024, time.August, 1, 12, 34, 56, 0, time.UTC) + html := renderTime(now) + expected := "2024-08-01 12:34:56" + if !strings.Contains(html, expected) { + t.Fatalf("expected formatted timestamp %s in markup", expected) + } +} + +func TestCounterEndpoint(t *testing.T) { + srv, err := New() + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/counter", nil) + resp := httptest.NewRecorder() + + srv.Router().ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + + body := resp.Body.String() + if !strings.Contains(body, "> 1<") { + t.Fatalf("expected counter to increment to 1, body: %q", body) + } +} + +func TestNotFound(t *testing.T) { + srv, err := New() + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/unknown", nil) + resp := httptest.NewRecorder() + + srv.Router().ServeHTTP(resp, req) + + if resp.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.Code) + } + if !strings.Contains(resp.Body.String(), "Route /unknown not found.") { + t.Fatalf("expected not found message, got %q", resp.Body.String()) + } +} diff --git a/luxtools.nimble b/luxtools.nimble deleted file mode 100755 index 0a3291d..0000000 --- a/luxtools.nimble +++ /dev/null @@ -1,18 +0,0 @@ -# Nimble package definition for the Luxtools demo web application - -version = "0.1.0" -author = "Luxtools Contributors" -description = "Scaffolding for a Nim + HTMX + TUI.CSS single-binary web application" -license = "MIT" -srcDir = "src" -bin = @["luxtools"] -binDir = "bin" - - -requires "nim >= 1.6.0" - -task build, "Build the optimized standalone binary": - exec "nim c -d:release --opt:speed src/luxtools.nimsrc/luxtools.nim" - -task test, "Execute the lightweight test suite": - exec "nim c --path:src -o:bin/test_rendering -r tests/test_rendering.nim " diff --git a/src/luxtools.nim b/src/luxtools.nim deleted file mode 100755 index d07a692..0000000 --- a/src/luxtools.nim +++ /dev/null @@ -1,178 +0,0 @@ -import std/[asynchttpserver, asyncdispatch, os, strformat, strutils, tables, times] - -const - indexPage = staticRead("templates/index.html") - -type - StaticFile = tuple[content: string, contentType: string] - -const - defaultContentType = "application/octet-stream" - contentTypeByExt = block: - var table = initTable[string, string]() - table[".js"] = "application/javascript" - table[".css"] = "text/css" - table[".html"] = "text/html; charset=utf-8" - table[".json"] = "application/json" - table[".png"] = "image/png" - table[".jpg"] = "image/jpeg" - table[".jpeg"] = "image/jpeg" - table[".svg"] = "image/svg+xml" - table[".gif"] = "image/gif" - table[".ico"] = "image/x-icon" - table[".ttf"] = "font/ttf" - table[".woff"] = "font/woff" - table[".woff2"] = "font/woff2" - table[".txt"] = "text/plain; charset=utf-8" - table - -proc guessContentType(path: string): string = - let ext = splitFile(path).ext.toLowerAscii() - if contentTypeByExt.hasKey(ext): - contentTypeByExt[ext] - else: - defaultContentType - -const staticRootDir = parentDir(currentSourcePath()) / "static" - -const staticFileEntries: seq[(string, StaticFile)] = block: - var entries: seq[(string, StaticFile)] = @[] - for path in walkDirRec(staticRootDir): - let fullPath = if isAbsolute(path): path else: staticRootDir / path - if not fileExists(fullPath): - continue - let relPath = relativePath(fullPath, staticRootDir) - if relPath.len == 0 or relPath.startsWith(".."): - continue - let normalizedRel = relPath.replace(DirSep, '/') - let urlPath = "/static/" & normalizedRel - let content = staticRead(fullPath) - let contentType = guessContentType(fullPath) - entries.add((urlPath, (content: content, contentType: contentType))) - entries - -proc findStaticFile(path: string; asset: var StaticFile): bool = - for entry in staticFileEntries: - if entry[0] == path: - asset = entry[1] - return true - false - -proc assetsRoot(): string = - absolutePath(joinPath(getAppDir(), "..", "assets")) - -var clickCount = 0 - -proc htmlHeaders(): HttpHeaders = - result = newHttpHeaders() - result.add("Content-Type", "text/html; charset=utf-8") - -proc assetHeaders(contentType: string): HttpHeaders = - result = newHttpHeaders() - result.add("Content-Type", contentType) - -proc renderCounter*(count: int): string = - &""" -
-

Clicks: {count}

- -
- """ - -proc renderTime(): string = - let now = now().format("yyyy-MM-dd HH:mm:ss") - &""" -
-

Server time: {now}

-
- """ - -proc sendHtml(req: Request, body: string) {.async, gcsafe.} = - await req.respond(Http200, body, htmlHeaders()) - -proc sendHtml(req: Request, code: HttpCode, body: string) {.async, gcsafe.} = - await req.respond(code, body, htmlHeaders()) - -proc sendAsset(req: Request, body: string, contentType: string) {.async, gcsafe.} = - await req.respond(Http200, body, assetHeaders(contentType)) - -proc trySendStatic(req: Request): Future[bool] {.async, gcsafe.} = - if req.reqMethod notin {HttpGet, HttpHead}: - return false - - let path = req.url.path - var asset: StaticFile - if not findStaticFile(path, asset): - return false - if req.reqMethod == HttpHead: - await req.respond(Http200, "", assetHeaders(asset.contentType)) - else: - await sendAsset(req, asset.content, asset.contentType) - return true - -proc trySendFileAsset(req: Request): Future[bool] {.async, gcsafe.} = - const assetPrefix = "/assets/" - - if req.reqMethod notin {HttpGet, HttpHead}: - return false - - let path = req.url.path - if not path.startsWith(assetPrefix): - return false - - if path.len == assetPrefix.len: - return false - - let relative = path[assetPrefix.len .. ^1] - if relative.len == 0 or relative.contains("..") or relative.contains('\\'): - return false - - let root = assetsRoot() - if not dirExists(root): - return false - - let assetPath = joinPath(root, relative) - if not fileExists(assetPath): - return false - - let contentType = guessContentType(assetPath) - if req.reqMethod == HttpHead: - await req.respond(Http200, "", assetHeaders(contentType)) - else: - await sendAsset(req, readFile(assetPath), contentType) - return true - -proc handleRequest(req: Request) {.async, gcsafe.} = - case req.url.path - of "/": - await sendHtml(req, indexPage) - of "/counter": - if req.reqMethod in {HttpPost, HttpPut}: - inc clickCount - await sendHtml(req, renderCounter(clickCount)) - of "/time": - await sendHtml(req, renderTime()) - else: - if await trySendStatic(req): - return - if await trySendFileAsset(req): - return - await sendHtml(req, Http404, """ -
-
- Error -

Route """ & req.url.path & """ not found.

-
-
- """) - -proc runServer() {.async.} = - let server = newAsyncHttpServer() - let port = Port(5000) - echo &"Luxtools web server running on http://127.0.0.1:{port.int}" - await server.serve(port, handleRequest) - -when isMainModule: - waitFor runServer() diff --git a/tests/test_rendering.nim b/tests/test_rendering.nim deleted file mode 100755 index f5519c5..0000000 --- a/tests/test_rendering.nim +++ /dev/null @@ -1,24 +0,0 @@ -import std/[os, strutils, unittest] -import ../src/luxtools - -const indexTemplate = staticRead("../src/templates/index.html") - -suite "rendering helpers": - test "counter markup references hx attributes": - let html = renderCounter(3) - check html.contains("hx-post=\"/counter\"") - check html.contains("id=\"counter\"") - check html.contains("3") - - test "index template uses embedded htmx build": - check indexTemplate.contains("/static/lib/htmx.2.0.7.min.js") - check indexTemplate.contains("") - check indexTemplate.contains("/static/lib/tuicss/tuicss.min.css") - check indexTemplate.contains("href=\"/static/lib/tuicss/tuicss.min.css\"") - check indexTemplate.contains("") - check not indexTemplate.contains("unpkg.com/htmx") - check not indexTemplate.contains("unpkg.com/tui-css") - - test "demo asset lives in assets directory": - let assetPath = joinPath(getAppDir(), "..", "assets", "demo.txt") - check fileExists(assetPath) diff --git a/src/static/lib/htmx.2.0.7.min.js b/web/static/lib/htmx.2.0.7.min.js similarity index 100% rename from src/static/lib/htmx.2.0.7.min.js rename to web/static/lib/htmx.2.0.7.min.js diff --git a/src/static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf b/web/static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf similarity index 100% rename from src/static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf rename to web/static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf diff --git a/src/static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf b/web/static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf similarity index 100% rename from src/static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf rename to web/static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf diff --git a/src/static/lib/tuicss/fonts/dos437.txt b/web/static/lib/tuicss/fonts/dos437.txt similarity index 100% rename from src/static/lib/tuicss/fonts/dos437.txt rename to web/static/lib/tuicss/fonts/dos437.txt diff --git a/src/static/lib/tuicss/images/bg-blue-black.png b/web/static/lib/tuicss/images/bg-blue-black.png similarity index 100% rename from src/static/lib/tuicss/images/bg-blue-black.png rename to web/static/lib/tuicss/images/bg-blue-black.png diff --git a/src/static/lib/tuicss/images/bg-blue-white.png b/web/static/lib/tuicss/images/bg-blue-white.png similarity index 100% rename from src/static/lib/tuicss/images/bg-blue-white.png rename to web/static/lib/tuicss/images/bg-blue-white.png diff --git a/src/static/lib/tuicss/images/bg-cyan-black.png b/web/static/lib/tuicss/images/bg-cyan-black.png similarity index 100% rename from src/static/lib/tuicss/images/bg-cyan-black.png rename to web/static/lib/tuicss/images/bg-cyan-black.png diff --git a/src/static/lib/tuicss/images/bg-cyan-white.png b/web/static/lib/tuicss/images/bg-cyan-white.png similarity index 100% rename from src/static/lib/tuicss/images/bg-cyan-white.png rename to web/static/lib/tuicss/images/bg-cyan-white.png diff --git a/src/static/lib/tuicss/images/bg-green-black.png b/web/static/lib/tuicss/images/bg-green-black.png similarity index 100% rename from src/static/lib/tuicss/images/bg-green-black.png rename to web/static/lib/tuicss/images/bg-green-black.png diff --git a/src/static/lib/tuicss/images/bg-green-white.png b/web/static/lib/tuicss/images/bg-green-white.png similarity index 100% rename from src/static/lib/tuicss/images/bg-green-white.png rename to web/static/lib/tuicss/images/bg-green-white.png diff --git a/src/static/lib/tuicss/images/bg-orange-black.png b/web/static/lib/tuicss/images/bg-orange-black.png similarity index 100% rename from src/static/lib/tuicss/images/bg-orange-black.png rename to web/static/lib/tuicss/images/bg-orange-black.png diff --git a/src/static/lib/tuicss/images/bg-orange-white.png b/web/static/lib/tuicss/images/bg-orange-white.png similarity index 100% rename from src/static/lib/tuicss/images/bg-orange-white.png rename to web/static/lib/tuicss/images/bg-orange-white.png diff --git a/src/static/lib/tuicss/images/bg-purple-black.png b/web/static/lib/tuicss/images/bg-purple-black.png similarity index 100% rename from src/static/lib/tuicss/images/bg-purple-black.png rename to web/static/lib/tuicss/images/bg-purple-black.png diff --git a/src/static/lib/tuicss/images/bg-purple-white.png b/web/static/lib/tuicss/images/bg-purple-white.png similarity index 100% rename from src/static/lib/tuicss/images/bg-purple-white.png rename to web/static/lib/tuicss/images/bg-purple-white.png diff --git a/src/static/lib/tuicss/images/bg-red-black.png b/web/static/lib/tuicss/images/bg-red-black.png similarity index 100% rename from src/static/lib/tuicss/images/bg-red-black.png rename to web/static/lib/tuicss/images/bg-red-black.png diff --git a/src/static/lib/tuicss/images/bg-red-white.png b/web/static/lib/tuicss/images/bg-red-white.png similarity index 100% rename from src/static/lib/tuicss/images/bg-red-white.png rename to web/static/lib/tuicss/images/bg-red-white.png diff --git a/src/static/lib/tuicss/images/bg-yellow-black.png b/web/static/lib/tuicss/images/bg-yellow-black.png similarity index 100% rename from src/static/lib/tuicss/images/bg-yellow-black.png rename to web/static/lib/tuicss/images/bg-yellow-black.png diff --git a/src/static/lib/tuicss/images/bg-yellow-white.png b/web/static/lib/tuicss/images/bg-yellow-white.png similarity index 100% rename from src/static/lib/tuicss/images/bg-yellow-white.png rename to web/static/lib/tuicss/images/bg-yellow-white.png diff --git a/src/static/lib/tuicss/images/scroll-blue.png b/web/static/lib/tuicss/images/scroll-blue.png similarity index 100% rename from src/static/lib/tuicss/images/scroll-blue.png rename to web/static/lib/tuicss/images/scroll-blue.png diff --git a/src/static/lib/tuicss/images/scroll-cyan.png b/web/static/lib/tuicss/images/scroll-cyan.png similarity index 100% rename from src/static/lib/tuicss/images/scroll-cyan.png rename to web/static/lib/tuicss/images/scroll-cyan.png diff --git a/src/static/lib/tuicss/images/scroll-green.png b/web/static/lib/tuicss/images/scroll-green.png similarity index 100% rename from src/static/lib/tuicss/images/scroll-green.png rename to web/static/lib/tuicss/images/scroll-green.png diff --git a/src/static/lib/tuicss/images/scroll-orange.png b/web/static/lib/tuicss/images/scroll-orange.png similarity index 100% rename from src/static/lib/tuicss/images/scroll-orange.png rename to web/static/lib/tuicss/images/scroll-orange.png diff --git a/src/static/lib/tuicss/images/scroll-purple.png b/web/static/lib/tuicss/images/scroll-purple.png similarity index 100% rename from src/static/lib/tuicss/images/scroll-purple.png rename to web/static/lib/tuicss/images/scroll-purple.png diff --git a/src/static/lib/tuicss/images/scroll-red.png b/web/static/lib/tuicss/images/scroll-red.png similarity index 100% rename from src/static/lib/tuicss/images/scroll-red.png rename to web/static/lib/tuicss/images/scroll-red.png diff --git a/src/static/lib/tuicss/images/scroll-white.png b/web/static/lib/tuicss/images/scroll-white.png similarity index 100% rename from src/static/lib/tuicss/images/scroll-white.png rename to web/static/lib/tuicss/images/scroll-white.png diff --git a/src/static/lib/tuicss/images/scroll-yellow.png b/web/static/lib/tuicss/images/scroll-yellow.png similarity index 100% rename from src/static/lib/tuicss/images/scroll-yellow.png rename to web/static/lib/tuicss/images/scroll-yellow.png diff --git a/src/static/lib/tuicss/tuicss.css b/web/static/lib/tuicss/tuicss.css similarity index 100% rename from src/static/lib/tuicss/tuicss.css rename to web/static/lib/tuicss/tuicss.css diff --git a/src/static/lib/tuicss/tuicss.js b/web/static/lib/tuicss/tuicss.js similarity index 100% rename from src/static/lib/tuicss/tuicss.js rename to web/static/lib/tuicss/tuicss.js diff --git a/src/static/lib/tuicss/tuicss.min.css b/web/static/lib/tuicss/tuicss.min.css similarity index 100% rename from src/static/lib/tuicss/tuicss.min.css rename to web/static/lib/tuicss/tuicss.min.css diff --git a/src/static/lib/tuicss/tuicss.min.js b/web/static/lib/tuicss/tuicss.min.js similarity index 100% rename from src/static/lib/tuicss/tuicss.min.js rename to web/static/lib/tuicss/tuicss.min.js diff --git a/src/templates/index.html b/web/templates/index.html similarity index 100% rename from src/templates/index.html rename to web/templates/index.html