Add CSS framework
7
.copilot/used-promts/03.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CHange the handling of static files:
|
||||||
|
- The entire `static/` directory is now compiled into the binary, not just specific files.
|
||||||
|
- The server serves static files from an in-memory table instead of reading from disk.
|
||||||
|
- The static files are linked by their paths under `/static/` in the HTML template.
|
||||||
|
- This includes the `/static/` part of the path
|
||||||
|
- The intention is that static assets can reference each other using relative paths. (e.g. CSS referencing images)
|
||||||
|
- The `www/` directory remains for user uploads, served from disk.
|
||||||
@@ -7,18 +7,19 @@ Luxtools is a minimal-but-complete scaffold for a retro-styled web application b
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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 are embedded at compile time via `staticRead`, so the server ships with everything it needs. Drop the compiled executable onto any host with the correct architecture and you're ready to run.
|
- **Single binary delivery**: HTML templates and the entire `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 `www/` directory remains available for user-provided uploads.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
luxtools/
|
luxtools/
|
||||||
├── bin/ # Output directory for release binaries
|
├── bin/ # Output directory for release binaries
|
||||||
├── assets/ # Static files served directly by the HTTP handler
|
├── static/ # Vendored assets compiled into the binary static table
|
||||||
├── src/
|
├── src/
|
||||||
│ └── luxtools.nim # Main server entrypoint
|
│ └── luxtools.nim # Main server entrypoint
|
||||||
├── templates/
|
├── templates/
|
||||||
│ └── index.html # HTMX-powered landing page (compiled into the binary)
|
│ └── index.html # HTMX-powered landing page (compiled into the binary)
|
||||||
|
├── www/ # Runtime asset directory served from disk
|
||||||
├── tests/
|
├── tests/
|
||||||
│ └── test_rendering.nim# Lightweight regression tests for HTML helpers
|
│ └── test_rendering.nim# Lightweight regression tests for HTML helpers
|
||||||
├── luxtools.nimble # Nimble manifest with build/test tasks
|
├── luxtools.nimble # Nimble manifest with build/test tasks
|
||||||
|
|||||||
BIN
src/luxtools
Executable file
121
src/luxtools.nim
@@ -1,8 +1,66 @@
|
|||||||
import std/[asynchttpserver, asyncdispatch, os, strformat, strutils, times]
|
import std/[asynchttpserver, asyncdispatch, os, strformat, strutils, tables, times]
|
||||||
|
|
||||||
const
|
const
|
||||||
indexPage = staticRead("../templates/index.html")
|
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(), "..", "www"))
|
||||||
|
|
||||||
var clickCount = 0
|
var clickCount = 0
|
||||||
|
|
||||||
proc htmlHeaders(): HttpHeaders =
|
proc htmlHeaders(): HttpHeaders =
|
||||||
@@ -40,46 +98,45 @@ proc sendHtml(req: Request, code: HttpCode, body: string) {.async, gcsafe.} =
|
|||||||
proc sendAsset(req: Request, body: string, contentType: string) {.async, gcsafe.} =
|
proc sendAsset(req: Request, body: string, contentType: string) {.async, gcsafe.} =
|
||||||
await req.respond(Http200, body, assetHeaders(contentType))
|
await req.respond(Http200, body, assetHeaders(contentType))
|
||||||
|
|
||||||
proc guessContentType(path: string): string =
|
proc trySendStatic(req: Request): Future[bool] {.async, gcsafe.} =
|
||||||
let ext = splitFile(path).ext.toLowerAscii()
|
|
||||||
case ext
|
|
||||||
of ".js":
|
|
||||||
result = "application/javascript"
|
|
||||||
of ".css":
|
|
||||||
result = "text/css"
|
|
||||||
of ".html":
|
|
||||||
result = "text/html; charset=utf-8"
|
|
||||||
of ".json":
|
|
||||||
result = "application/json"
|
|
||||||
of ".png":
|
|
||||||
result = "image/png"
|
|
||||||
of ".jpg", ".jpeg":
|
|
||||||
result = "image/jpeg"
|
|
||||||
of ".svg":
|
|
||||||
result = "image/svg+xml"
|
|
||||||
of ".gif":
|
|
||||||
result = "image/gif"
|
|
||||||
of ".ico":
|
|
||||||
result = "image/x-icon"
|
|
||||||
else:
|
|
||||||
result = "application/octet-stream"
|
|
||||||
|
|
||||||
proc trySendAsset(req: Request): Future[bool] {.async, gcsafe.} =
|
|
||||||
if req.reqMethod notin {HttpGet, HttpHead}:
|
if req.reqMethod notin {HttpGet, HttpHead}:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
let path = req.url.path
|
let path = req.url.path
|
||||||
if path.len <= 1 or path[0] != '/':
|
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
|
return false
|
||||||
|
|
||||||
let relative = path[1..^1]
|
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('\\'):
|
if relative.len == 0 or relative.contains("..") or relative.contains('\\'):
|
||||||
return false
|
return false
|
||||||
|
|
||||||
let assetPath = joinPath(absolutePath(joinPath(getAppDir(), "..", "assets")), relative)
|
let root = assetsRoot()
|
||||||
|
if not dirExists(root):
|
||||||
|
return false
|
||||||
|
|
||||||
|
let assetPath = joinPath(root, relative)
|
||||||
if not fileExists(assetPath):
|
if not fileExists(assetPath):
|
||||||
return false
|
return false
|
||||||
|
|
||||||
let contentType = guessContentType(assetPath)
|
let contentType = guessContentType(assetPath)
|
||||||
if req.reqMethod == HttpHead:
|
if req.reqMethod == HttpHead:
|
||||||
await req.respond(Http200, "", assetHeaders(contentType))
|
await req.respond(Http200, "", assetHeaders(contentType))
|
||||||
@@ -98,7 +155,9 @@ proc handleRequest(req: Request) {.async, gcsafe.} =
|
|||||||
of "/time":
|
of "/time":
|
||||||
await sendHtml(req, renderTime())
|
await sendHtml(req, renderTime())
|
||||||
else:
|
else:
|
||||||
if await trySendAsset(req):
|
if await trySendStatic(req):
|
||||||
|
return
|
||||||
|
if await trySendFileAsset(req):
|
||||||
return
|
return
|
||||||
await sendHtml(req, Http404, """
|
await sendHtml(req, Http404, """
|
||||||
<div class="tui-window">
|
<div class="tui-window">
|
||||||
|
|||||||
BIN
static/lib/tuicss/fonts/Perfect DOS VGA 437 Win.ttf
Normal file
BIN
static/lib/tuicss/fonts/Perfect DOS VGA 437.ttf
Normal file
72
static/lib/tuicss/fonts/dos437.txt
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/
|
||||||
|
/(_____________ ____
|
||||||
|
\ /______)\ | |
|
||||||
|
:\ | / \:| |:::::::::: : .. . : .. . . :. .
|
||||||
|
\_____| / | \| |______
|
||||||
|
___ / ________ \... . . .
|
||||||
|
\______________ \ | | /.. . . . . .
|
||||||
|
\ |__| /
|
||||||
|
--x--x-----x----\______ |-/_____/-x--x-xx--x-- - -x -- - - -- - - -
|
||||||
|
. . . . . . . . . . . .\____|. . . . . .
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
>> perfect dos vga 437 - general information >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
"Perfect DOS VGA 437" and "Perfect DOS VGA 437 Win" are truetype fonts
|
||||||
|
designed to emulate the MS-DOS/Text mode standard font, used on VGA monitors,
|
||||||
|
with the 437 Codepage (standard US/International). This is a "bitmap" font,
|
||||||
|
meaning it emulates a bitmap font and can only be used at a given size (8 or
|
||||||
|
multiples of it like 16, 24, 32, etc). It's optimized for Flash too, so it
|
||||||
|
won't produce antialias if used at round positions.
|
||||||
|
|
||||||
|
There are two fonts available. "Perfect DOS VGA 437" uses the original DOS
|
||||||
|
codepage 437. It should be used, for example, if you're opening DOS ASCII
|
||||||
|
files on notepad or another windows-based editor. Since it's faithful to the
|
||||||
|
original DOS codes, it won't accent correctly in windows ("é" would produce
|
||||||
|
something different, not an "e" with an acute).
|
||||||
|
|
||||||
|
There's also "Perfect DOS VGA 437 Win" which is the exactly same font adapted
|
||||||
|
to a windows codepage. This should use accented characters correctly but won't
|
||||||
|
work if you're opening a DOS-based text file.
|
||||||
|
|
||||||
|
UPDATE: this is a new version, updated in august/2008. It has fixed leading
|
||||||
|
metrics for Mac systems.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
>> perfect dos vga 437 - creation process >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
This font was created to be used on a Flash-based ANSi viewer I'm working. To
|
||||||
|
create it, I created a small Quick Basic program to write all characters on
|
||||||
|
screen,
|
||||||
|
|
||||||
|
CLS
|
||||||
|
FOR l = 0 TO 255
|
||||||
|
charWrite 1 + (l MOD 20), 1 + (l \ 20) * 6 + (l MOD 2), LTRIM$(RTRIM$(STR$(l))) + CHR$(l)
|
||||||
|
NEXT
|
||||||
|
SUB charWrite (lin, col, char$)
|
||||||
|
DEF SEG = &HB800
|
||||||
|
FOR i = 1 TO LEN(char$)
|
||||||
|
POKE ((lin - 1) * 160) + ((col - 2 + i) * 2), ASC(MID$(char$, i, 1))
|
||||||
|
IF (i = LEN(char$)) THEN POKE ((lin - 1) * 160) + ((col - 2 + i) * 2) + 1, 113
|
||||||
|
NEXT
|
||||||
|
END SUB
|
||||||
|
|
||||||
|
Then captured the text screen using SCREEN THIEF (a very, very old screen
|
||||||
|
capture TSR program which converts text screens to images accurately). I then
|
||||||
|
recreated the font polygon by polygon on Fontlab, while looking at the image
|
||||||
|
on Photoshop. No conversion took place.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
>> author >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
zeh fernando remembers the old days. SMASH DAH FUCKING ENTAH.
|
||||||
|
|
||||||
|
http://www.fatorcaos.com.br
|
||||||
|
|
||||||
|
rorshack ^ maiden brazil
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
^zehPULLSdahTRICK^kudosOUTtoWHOkeepsITreal^smashDAHfuckingENTAH!!!^lowres4ever^
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
BIN
static/lib/tuicss/images/bg-blue-black.png
Normal file
|
After Width: | Height: | Size: 166 B |
BIN
static/lib/tuicss/images/bg-blue-white.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
static/lib/tuicss/images/bg-cyan-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
static/lib/tuicss/images/bg-cyan-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
static/lib/tuicss/images/bg-green-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
static/lib/tuicss/images/bg-green-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
static/lib/tuicss/images/bg-orange-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
static/lib/tuicss/images/bg-orange-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
static/lib/tuicss/images/bg-purple-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
static/lib/tuicss/images/bg-purple-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
static/lib/tuicss/images/bg-red-black.png
Normal file
|
After Width: | Height: | Size: 166 B |
BIN
static/lib/tuicss/images/bg-red-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
static/lib/tuicss/images/bg-yellow-black.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
static/lib/tuicss/images/bg-yellow-white.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
static/lib/tuicss/images/scroll-blue.png
Normal file
|
After Width: | Height: | Size: 166 B |
BIN
static/lib/tuicss/images/scroll-cyan.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
static/lib/tuicss/images/scroll-green.png
Normal file
|
After Width: | Height: | Size: 167 B |
BIN
static/lib/tuicss/images/scroll-orange.png
Normal file
|
After Width: | Height: | Size: 167 B |
BIN
static/lib/tuicss/images/scroll-purple.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
static/lib/tuicss/images/scroll-red.png
Normal file
|
After Width: | Height: | Size: 167 B |
BIN
static/lib/tuicss/images/scroll-white.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
static/lib/tuicss/images/scroll-yellow.png
Normal file
|
After Width: | Height: | Size: 167 B |
2704
static/lib/tuicss/tuicss.css
Normal file
256
static/lib/tuicss/tuicss.js
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Replacement for jQuery's $(document).ready() function.
|
||||||
|
* This is handy in making sure stuff fires after the DOM is ready to be touched.
|
||||||
|
* Stolen from:https://stackoverflow.com/a/53601942/344028
|
||||||
|
*
|
||||||
|
* @param fn Callback.
|
||||||
|
*/
|
||||||
|
function domReady(fn) {
|
||||||
|
// If we're early to the party
|
||||||
|
document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
|
||||||
|
// If late; I mean on time.
|
||||||
|
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TuiTabs controller
|
||||||
|
*/
|
||||||
|
function tabsController() {
|
||||||
|
// Get all the tab elements (typically li tags).
|
||||||
|
const tabs = document.getElementsByClassName('tui-tab');
|
||||||
|
|
||||||
|
if (!tabs.length) {
|
||||||
|
// No tabs found, return early and save a couple CPU cycles.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tab of tabs) {
|
||||||
|
// Add click listeners to them.
|
||||||
|
tab.addEventListener('click', function (e) {
|
||||||
|
|
||||||
|
// Check if the clicked tab is disabled
|
||||||
|
if(e.target.classList.contains("disabled")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the 'active' class from any and all tabs.
|
||||||
|
for (const otherTab of tabs) {
|
||||||
|
otherTab.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the content element.
|
||||||
|
const tabContents = document.getElementsByClassName('tui-tab-content');
|
||||||
|
|
||||||
|
if (tabContents) {
|
||||||
|
for (const tabContent of tabContents) {
|
||||||
|
// Hide all tab contents.
|
||||||
|
tabContent.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw 'No tab content elements found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the id of the tab contents we want to show.
|
||||||
|
const tabContentId = e.target.getAttribute('data-tab-content');
|
||||||
|
|
||||||
|
if (tabContentId) {
|
||||||
|
const tabContent = document.getElementById(tabContentId);
|
||||||
|
if (tabContent) {
|
||||||
|
// Show the tab contents.
|
||||||
|
tabContent.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
throw 'No tab content element with id "' + tabContentId + '" found.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We are not going to throw an error here, since we could make the tab do something else that a tab
|
||||||
|
// normally wouldn't do.
|
||||||
|
|
||||||
|
// Set the clicked tab to have the 'active' class so we can use it in the next part.
|
||||||
|
e.target.classList.add('active');
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab the first tab with the active class.
|
||||||
|
const activeTab = document.querySelector('.tui-tab.active');
|
||||||
|
if (activeTab) {
|
||||||
|
// Now click it 'click' it.
|
||||||
|
activeTab.click();
|
||||||
|
} else {
|
||||||
|
// Nothing found, just click the first tab foud.
|
||||||
|
tabs[0].click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date/time field controller
|
||||||
|
*/
|
||||||
|
function datetimeController() {
|
||||||
|
// Get date/time elements.
|
||||||
|
const clocks = document.getElementsByClassName('tui-datetime');
|
||||||
|
|
||||||
|
if (!clocks.length) {
|
||||||
|
// No date time elements found, return early and save a couple CPU cycles.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick off our clock interval stuff.
|
||||||
|
datetimeInterval();
|
||||||
|
|
||||||
|
// Synchronize time and set interval to control the clocks
|
||||||
|
setTimeout(() => {
|
||||||
|
setInterval(datetimeInterval, 1000);
|
||||||
|
}, 1000 - new Date().getMilliseconds());
|
||||||
|
|
||||||
|
function datetimeInterval() {
|
||||||
|
for (const clock of clocks) {
|
||||||
|
if (clock === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the format we want to display in the element.
|
||||||
|
let format = clock.getAttribute('data-format');
|
||||||
|
|
||||||
|
// parse out the date and time into constants.
|
||||||
|
const today = new Date();
|
||||||
|
const month = (today.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = today.getDate().toString().padStart(2, '0');
|
||||||
|
const dayOfWeek = (today.getDay() + 1).toString().padStart(2, '0');
|
||||||
|
const year = today.getFullYear().toString();
|
||||||
|
const hour = today.getHours().toString().padStart(2, '0');
|
||||||
|
const hour12 = (parseInt(hour) + 24) % '12' || '12';
|
||||||
|
const minute = today.getMinutes().toString().padStart(2, '0');
|
||||||
|
const second = today.getSeconds().toString().padStart(2, '0');
|
||||||
|
const ampm = parseInt(hour) >= 12 ? 'PM' : 'AM';
|
||||||
|
|
||||||
|
// Replace based on the format.
|
||||||
|
format = format.replace('M', month);
|
||||||
|
format = format.replace('d', day);
|
||||||
|
format = format.replace('e', dayOfWeek);
|
||||||
|
format = format.replace('y', year);
|
||||||
|
format = format.replace('H', hour);
|
||||||
|
format = format.replace('h', hour12);
|
||||||
|
format = format.replace('m', minute);
|
||||||
|
format = format.replace('s', second);
|
||||||
|
format = format.replace('a', ampm);
|
||||||
|
|
||||||
|
// Show it in the element.
|
||||||
|
clock.innerHTML = format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidenav Controller
|
||||||
|
* There should only side navigation element at the moment.
|
||||||
|
*/
|
||||||
|
function sidenavController() {
|
||||||
|
// Get the side navigation button (there should be only one, but if not, we are getting the first one).
|
||||||
|
const sideNavButton = document.querySelector('.tui-sidenav-button');
|
||||||
|
|
||||||
|
if (!sideNavButton) {
|
||||||
|
// No side navigation button found, return early and save a couple CPU cycles.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the click event listener to the buttons.
|
||||||
|
sideNavButton.addEventListener('click', () => {
|
||||||
|
// Get the side navigation element (there should be only one, but if not, we are getting the first one).
|
||||||
|
const sideNav = document.querySelector('.tui-sidenav');
|
||||||
|
|
||||||
|
if (sideNav) {
|
||||||
|
if (sideNav.classList.contains('active')) {
|
||||||
|
sideNav.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
sideNav.classList.add('active');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw 'No sidenav element found.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal controller
|
||||||
|
*/
|
||||||
|
function modalController() {
|
||||||
|
// Get the overlap (overlay) element (there should be only one, but if not, we are getting the first one).
|
||||||
|
const tuiOverlap = document.querySelector('.tui-overlap');
|
||||||
|
|
||||||
|
if (!tuiOverlap) {
|
||||||
|
// No overlap found element, return early and save a couple CPU cycles.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find modal buttons.
|
||||||
|
const modalButtons = document.getElementsByClassName('tui-modal-button');
|
||||||
|
for (const modalButton of modalButtons) {
|
||||||
|
// Add the click event listener to the buttons.
|
||||||
|
modalButton.addEventListener('click', (e) => {
|
||||||
|
// Show the overlap.
|
||||||
|
tuiOverlap.classList.add('active');
|
||||||
|
|
||||||
|
// Get the display element for the modal.
|
||||||
|
const modalId = e.target.getAttribute('data-modal');
|
||||||
|
|
||||||
|
if (modalId) {
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
// Show it.
|
||||||
|
modal.classList.add('active');
|
||||||
|
} else {
|
||||||
|
throw 'No modal element with id of "' + modalId + '" found.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw 'Modal close button data-modal attribute is empty or not set.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the close modal buttons.
|
||||||
|
const modalCloseButtons = document.getElementsByClassName('tui-modal-close-button');
|
||||||
|
|
||||||
|
if (modalButtons.length > 0 && !modalCloseButtons.length) {
|
||||||
|
// A modal without a close button, is a bad modal.
|
||||||
|
throw 'No modal close buttons found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const modalCloseButton of modalCloseButtons) {
|
||||||
|
// Add the click event listener to the buttons.
|
||||||
|
modalCloseButton.addEventListener('click', (e) => {
|
||||||
|
// Hide the the overlap.
|
||||||
|
tuiOverlap.classList.remove('active');
|
||||||
|
|
||||||
|
// Get the display element id for the modal.
|
||||||
|
const modalId = e.target.getAttribute('data-modal');
|
||||||
|
|
||||||
|
if (modalId) {
|
||||||
|
// Get the modal element.
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
// Hide it.
|
||||||
|
modal.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
throw 'No modal element with id of "' + modalId + '" found.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw 'Modal close button data-modal attribute is empty or not set.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init: This is at the bottom to make sure it is fired correctly.
|
||||||
|
*/
|
||||||
|
domReady(function () {
|
||||||
|
tabsController();
|
||||||
|
datetimeController();
|
||||||
|
sidenavController();
|
||||||
|
modalController();
|
||||||
|
});
|
||||||
1
static/lib/tuicss/tuicss.min.css
vendored
Normal file
1
static/lib/tuicss/tuicss.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
function domReady(t){document.addEventListener("DOMContentLoaded",t),"interactive"!==document.readyState&&"complete"!==document.readyState||t()}function tabsController(){const t=document.getElementsByClassName("tui-tab");if(!t.length)return;for(const e of t)e.addEventListener("click",function(e){if(e.target.classList.contains("disabled"))return;for(const e of t)e.classList.remove("active");const o=document.getElementsByClassName("tui-tab-content");if(!o)throw"No tab content elements found.";for(const t of o)t.style.display="none";const n=e.target.getAttribute("data-tab-content");if(n){const t=document.getElementById(n);if(!t)throw'No tab content element with id "'+n+'" found.';t.style.display="block"}e.target.classList.add("active")});const e=document.querySelector(".tui-tab.active");e?e.click():t[0].click()}function datetimeController(){const t=document.getElementsByClassName("tui-datetime");function e(){for(const e of t){if(null===e)continue;let t=e.getAttribute("data-format");const o=new Date,n=(o.getMonth()+1).toString().padStart(2,"0"),a=o.getDate().toString().padStart(2,"0"),c=(o.getDay()+1).toString().padStart(2,"0"),s=o.getFullYear().toString(),i=o.getHours().toString().padStart(2,"0"),l=(parseInt(i)+24)%"12"||"12",r=o.getMinutes().toString().padStart(2,"0"),d=o.getSeconds().toString().padStart(2,"0"),u=parseInt(i)>=12?"PM":"AM";t=(t=(t=(t=(t=(t=(t=(t=(t=t.replace("M",n)).replace("d",a)).replace("e",c)).replace("y",s)).replace("H",i)).replace("h",l)).replace("m",r)).replace("s",d)).replace("a",u),e.innerHTML=t}}t.length&&(e(),setTimeout(()=>{setInterval(e,1e3)},1e3-(new Date).getMilliseconds()))}function sidenavController(){const t=document.querySelector(".tui-sidenav-button");t&&t.addEventListener("click",()=>{const t=document.querySelector(".tui-sidenav");if(!t)throw"No sidenav element found.";t.classList.contains("active")?t.classList.remove("active"):t.classList.add("active")})}function modalController(){const t=document.querySelector(".tui-overlap");if(!t)return;const e=document.getElementsByClassName("tui-modal-button");for(const o of e)o.addEventListener("click",e=>{t.classList.add("active");const o=e.target.getAttribute("data-modal");if(!o)throw"Modal close button data-modal attribute is empty or not set.";{const t=document.getElementById(o);if(!t)throw'No modal element with id of "'+o+'" found.';t.classList.add("active")}});const o=document.getElementsByClassName("tui-modal-close-button");if(e.length>0&&!o.length)throw"No modal close buttons found.";for(const e of o)e.addEventListener("click",e=>{t.classList.remove("active");const o=e.target.getAttribute("data-modal");if(!o)throw"Modal close button data-modal attribute is empty or not set.";{const t=document.getElementById(o);if(!t)throw'No modal element with id of "'+o+'" found.';t.classList.remove("active")}})}domReady(function(){tabsController(),datetimeController(),sidenavController(),modalController()});
|
||||||
@@ -6,9 +6,10 @@
|
|||||||
<title>Luxtools · Nim + HTMX demo</title>
|
<title>Luxtools · Nim + HTMX demo</title>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://unpkg.com/tui-css@2.3.1/dist/tui.css"
|
href="/static/lib/tuicss/tuicss.min.css"
|
||||||
/>
|
/>
|
||||||
<script src="/lib/htmx.2.0.7.min.js" defer></script>
|
<script src="/static/lib/htmx.2.0.7.min.js" defer></script>
|
||||||
|
<script src="/static/lib/tuicss/tuicss.min.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="tui-bg-black">
|
<body class="tui-bg-black">
|
||||||
<main class="tui-container tui-window" style="margin-top: 2rem;">
|
<main class="tui-container tui-window" style="margin-top: 2rem;">
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ suite "rendering helpers":
|
|||||||
check html.contains("id=\"counter\"")
|
check html.contains("id=\"counter\"")
|
||||||
check html.contains("3")
|
check html.contains("3")
|
||||||
|
|
||||||
test "index template uses vendored htmx build":
|
test "index template uses embedded htmx build":
|
||||||
check indexTemplate.contains("/lib/htmx.2.0.7.min.js")
|
check indexTemplate.contains("/static/lib/htmx.2.0.7.min.js")
|
||||||
check indexTemplate.contains("<script src=\"/lib/htmx.2.0.7.min.js\" defer></script>")
|
check indexTemplate.contains("<script src=\"/static/lib/htmx.2.0.7.min.js\" defer></script>")
|
||||||
|
check indexTemplate.contains("/static/lib/tuicss/tuicss.min.css")
|
||||||
|
check indexTemplate.contains("href=\"/static/lib/tuicss/tuicss.min.css\"")
|
||||||
|
check indexTemplate.contains("<script src=\"/static/lib/tuicss/tuicss.min.js\" defer></script>")
|
||||||
check not indexTemplate.contains("unpkg.com/htmx")
|
check not indexTemplate.contains("unpkg.com/htmx")
|
||||||
|
check not indexTemplate.contains("unpkg.com/tui-css")
|
||||||
|
|
||||||
test "vendored htmx asset lives in assets directory":
|
test "demo asset lives in www directory":
|
||||||
let assetPath = joinPath(getAppDir(), "..", "assets", "lib", "htmx.2.0.7.min.js")
|
let assetPath = joinPath(getAppDir(), "..", "www", "demo.txt")
|
||||||
check fileExists(assetPath)
|
check fileExists(assetPath)
|
||||||
|
|||||||
1
www/demo.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
This is a demo asset served directly from the filesystem assets handler.
|
||||||