Compare commits
16
Commits
8e60f0472b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee8d9ead1 | ||
|
|
228871648d | ||
|
|
c56d1f7f8f | ||
|
|
7d90e1853d | ||
|
|
6722497469 | ||
|
|
5450b6a82c | ||
|
|
e58e4bc13e | ||
|
|
738a02ca1d | ||
|
|
3f10dd4c07 | ||
|
|
74c28c4f35 | ||
|
|
9323d6e9c3 | ||
|
|
423c5b99e2 | ||
|
|
71becb2eb0 | ||
|
|
bf1a5a4b6a | ||
|
|
264caa911a | ||
|
|
7e4c508329 |
@@ -0,0 +1 @@
|
||||
bin/
|
||||
@@ -1,8 +1,9 @@
|
||||
BINARY = zeitkort-web
|
||||
BINARY = poc-zeitkort-web
|
||||
|
||||
.PHONY: build run clean
|
||||
|
||||
build:
|
||||
build: clean
|
||||
go clean -cache
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./bin/$(BINARY) .
|
||||
|
||||
run: build
|
||||
|
||||
@@ -11,7 +11,7 @@ records in memory.
|
||||
- Companies, assignments, tasks, work sessions, and invoice-number records are
|
||||
held in process memory only.
|
||||
- Settings edited from the UI are also in-memory only.
|
||||
- Everything resets automatically every **10 minutes** after the process starts.
|
||||
- Everything resets automatically every **20 minutes** after the process starts.
|
||||
- A sticky, high-visibility countdown banner shows exactly when the next reset will happen.
|
||||
- Restarting the process also clears everything immediately.
|
||||
- `config.json` is read only at startup for the listen address and initial
|
||||
@@ -23,9 +23,11 @@ records in memory.
|
||||
- Add / edit / delete companies, assignments, tasks, and work sessions.
|
||||
- Add manual time entries.
|
||||
- Generate the Faroese invoice PDF directly from the in-memory data.
|
||||
- Edit sender/company invoice defaults for the current 10-minute in-memory run.
|
||||
- Edit sender/company invoice defaults for the current 20-minute in-memory run.
|
||||
- Open the **About** menu item for a concise explanation of the POC features and implementation.
|
||||
|
||||

|
||||
|
||||
## Build
|
||||
|
||||
Requires Go 1.26+ as declared by the original project.
|
||||
@@ -59,7 +61,7 @@ There are no API URL, username, or password fields in this version.
|
||||
./zeitkort-web -config config.json
|
||||
```
|
||||
|
||||
The app logs a reset message every 10 minutes when the in-memory POC state is
|
||||
The app logs a reset message every 20 minutes when the in-memory POC state is
|
||||
cleared. The browser also shows a large countdown and reloads shortly after the
|
||||
reset so the cleared state is visible.
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
REMOTE_MACHINE: bl@flo-homeserver
|
||||
BINARY_DIR: ./bin
|
||||
DEV_BINARY_DIR: ./local
|
||||
BINARY: "{{.BINARY_DIR}}/zeitkort-web-poc"
|
||||
DEV_BINARY: "{{.DEV_BINARY_DIR}}/"
|
||||
|
||||
tasks:
|
||||
|
||||
build:
|
||||
desc: runs vet, fmt, and build
|
||||
deps: [vet, fmt]
|
||||
cmds:
|
||||
- rm -rf {{.BINARY_DIR}}/*
|
||||
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o {{.BINARY}} .
|
||||
|
||||
dev-build:
|
||||
desc: runs vet, fmt, and builds dev binary
|
||||
deps: [vet, fmt]
|
||||
cmds:
|
||||
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o {{.DEV_BINARY}}/zeitkort-web-poc .
|
||||
|
||||
deploy:
|
||||
desc: build, stage binary, and deploy to remote server (preserves config.json)
|
||||
deps: [build]
|
||||
cmds:
|
||||
- ssh {{.REMOTE_MACHINE}} "mkdir -p /tmp/zeitkort-web-poc-deploy"
|
||||
- rsync -avz --delete {{.BINARY}} {{.REMOTE_MACHINE}}:/tmp/zeitkort-web-poc-deploy/
|
||||
|
||||
- ssh -t {{.REMOTE_MACHINE}} "sudo mkdir -p /opt/zeitkort-web-poc && sudo rsync -a /tmp/zeitkort-web-poc-deploy/zeitkort-web-poc /opt/zeitkort-web-poc/zeitkort-web-poc && sudo chown zeitkort-web-poc:zeitkort-web-poc /opt/zeitkort-web-poc/zeitkort-web-poc && sudo systemctl restart zeitkort-web-poc"
|
||||
|
||||
- ssh {{.REMOTE_MACHINE}} "rm -rf /tmp/zeitkort-web-poc-deploy"
|
||||
|
||||
dev-start:
|
||||
desc: build dev binary, copy assets, and start locally
|
||||
dir: "{{ .DEV_BINARY }}"
|
||||
cmds:
|
||||
- "rm -rf *"
|
||||
- go tool task dev-build
|
||||
- "cp ../config.json ."
|
||||
- "./zeitkort-web-poc -config config.json"
|
||||
|
||||
vet:
|
||||
cmds:
|
||||
- go vet ./...
|
||||
|
||||
fmt:
|
||||
cmds:
|
||||
- go fmt ./...
|
||||
|
||||
clean:
|
||||
cmds:
|
||||
- rm -f {{.BINARY}}
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// Config holds the web-only proof-of-concept server settings plus invoice
|
||||
// defaults. It is read at startup only; edits made on the Settings page are
|
||||
// kept in memory and are reset with the rest of the POC data every 10 minutes.
|
||||
// kept in memory and are reset with the rest of the POC data every 20 minutes.
|
||||
type Config struct {
|
||||
// Where the web server listens. Caddy can reverse-proxy to this.
|
||||
ListenAddr string `json:"listen_addr"`
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 486 KiB |
@@ -14,7 +14,7 @@ import (
|
||||
// Version is set at build time via -ldflags.
|
||||
var Version = "dev"
|
||||
|
||||
const resetInterval = 10 * time.Minute
|
||||
const resetInterval = 20 * time.Minute
|
||||
|
||||
// Server holds shared dependencies.
|
||||
type Server struct {
|
||||
@@ -44,6 +44,8 @@ func main() {
|
||||
errl.Fatalf("repository: %v", err)
|
||||
}
|
||||
|
||||
seedDemoData(repo)
|
||||
|
||||
s := &Server{
|
||||
Cfg: cfg,
|
||||
initialCfg: cfg,
|
||||
@@ -79,6 +81,10 @@ func (s *Server) resetLoop(interval time.Duration) {
|
||||
if resetter, ok := s.repo.(interface{ Reset() }); ok {
|
||||
resetter.Reset()
|
||||
}
|
||||
|
||||
// Re-seed demo data after reset
|
||||
seedDemoData(s.repo)
|
||||
|
||||
s.mu.Lock()
|
||||
s.Cfg = s.initialCfg
|
||||
s.resetAt = time.Now().Add(interval)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"zeitkort-web/repository"
|
||||
)
|
||||
|
||||
func seedDemoData(repo repository.Repository) {
|
||||
if resetter, ok := repo.(interface{ Reset() }); ok {
|
||||
resetter.Reset()
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
loc := now.Location()
|
||||
|
||||
mukki, _ := repo.InsertCompany(repository.Company{
|
||||
Name: "Mukki",
|
||||
Address: "Snoppiesgøta 1, FO-100 Tórshavn",
|
||||
ContactEmail: "kontakt@mukki.fo",
|
||||
ContactPhone: "+298 210000",
|
||||
HourlyRate: 850,
|
||||
})
|
||||
|
||||
teltlar, _ := repo.InsertCompany(repository.Company{
|
||||
Name: "Teldlar og Teldur",
|
||||
Address: "Teldutrøð 5, FO-100 Tórshavn",
|
||||
ContactEmail: "info@teltlar.fo",
|
||||
ContactPhone: "+298 310000",
|
||||
HourlyRate: 750,
|
||||
})
|
||||
|
||||
sjogvur, _ := repo.InsertCompany(repository.Company{
|
||||
Name: "Sjógvur.fo",
|
||||
Address: "Vesturgøta 22, FO-100 Tórshavn",
|
||||
ContactEmail: "heiðri@sjogvur.fo",
|
||||
ContactPhone: "+298 410000",
|
||||
HourlyRate: 650,
|
||||
})
|
||||
|
||||
mukkiAPI, _ := repo.InsertAssignment(repository.Assignment{
|
||||
CompanyID: mukki.ID,
|
||||
Name: "Microservices Backend Migration",
|
||||
Description: "Refactoring legacy monolith into Go microservices.",
|
||||
HourlyRate: 850,
|
||||
Status: "active",
|
||||
})
|
||||
mukkiInfra, _ := repo.InsertAssignment(repository.Assignment{
|
||||
CompanyID: mukki.ID,
|
||||
Name: "Kubernetes Cluster Setup",
|
||||
Description: "Deploying K3s cluster.",
|
||||
HourlyRate: 850,
|
||||
Status: "active",
|
||||
})
|
||||
|
||||
teltlarERP, _ := repo.InsertAssignment(repository.Assignment{
|
||||
CompanyID: teltlar.ID,
|
||||
Name: "ERP Integration Module",
|
||||
Description: "REST API bridge between SQL Server and frontend.",
|
||||
HourlyRate: 750,
|
||||
Status: "active",
|
||||
})
|
||||
|
||||
sjogvurWeb, _ := repo.InsertAssignment(repository.Assignment{
|
||||
CompanyID: sjogvur.ID,
|
||||
Name: "Corporate Website Redesign",
|
||||
Description: "Modernizing frontend with Tailwind and static generation.",
|
||||
HourlyRate: 650,
|
||||
Status: "active",
|
||||
})
|
||||
|
||||
tMukkiAPI, _ := repo.InsertTask(repository.Task{AssignmentID: mukkiAPI.ID, Name: "Design gRPC Protobufs"})
|
||||
tMukkiAPI2, _ := repo.InsertTask(repository.Task{AssignmentID: mukkiAPI.ID, Name: "Implement Auth Middleware"})
|
||||
tMukkiInfra, _ := repo.InsertTask(repository.Task{AssignmentID: mukkiInfra.ID, Name: "Ansible Playbooks"})
|
||||
|
||||
tTeltlar, _ := repo.InsertTask(repository.Task{AssignmentID: teltlarERP.ID, Name: "SQL Stored Procedures"})
|
||||
tTeltlar2, _ := repo.InsertTask(repository.Task{AssignmentID: teltlarERP.ID, Name: "API Endpoints"})
|
||||
|
||||
tSjogvur, _ := repo.InsertTask(repository.Task{AssignmentID: sjogvurWeb.ID, Name: "Design System"})
|
||||
tSjogvur2, _ := repo.InsertTask(repository.Task{AssignmentID: sjogvurWeb.ID, Name: "SEO Optimization"})
|
||||
|
||||
addSession := func(assignID, taskID int64, daysAgo int, hours float64) {
|
||||
start := time.Date(now.Year(), now.Month(), now.Day()-daysAgo, 9, 0, 0, 0, loc)
|
||||
end := start.Add(time.Duration(hours * float64(time.Hour)))
|
||||
|
||||
task, _ := repo.GetTaskByID(taskID)
|
||||
desc := fmt.Sprintf("%d - %s", task.Number, task.Name)
|
||||
|
||||
repo.InsertManualWorkSession(repository.WorkSession{
|
||||
AssignmentID: assignID,
|
||||
TaskID: taskID,
|
||||
TaskDescription: desc,
|
||||
StartTime: start,
|
||||
EndTime: &end,
|
||||
})
|
||||
}
|
||||
|
||||
addSession(mukkiAPI.ID, tMukkiAPI.ID, 5, 4.5)
|
||||
addSession(mukkiAPI.ID, tMukkiAPI2.ID, 3, 2.0)
|
||||
addSession(mukkiInfra.ID, tMukkiInfra.ID, 1, 6.0)
|
||||
|
||||
addSession(teltlarERP.ID, tTeltlar.ID, 4, 3.5)
|
||||
addSession(teltlarERP.ID, tTeltlar2.ID, 2, 5.0)
|
||||
|
||||
addSession(sjogvurWeb.ID, tSjogvur.ID, 6, 2.5)
|
||||
addSession(sjogvurWeb.ID, tSjogvur2.ID, 0, 1.5)
|
||||
|
||||
runningStart := now.Add(-45 * time.Minute)
|
||||
task, _ := repo.GetTaskByID(tSjogvur2.ID)
|
||||
desc := fmt.Sprintf("%d - %s", task.Number, task.Name)
|
||||
|
||||
runningSession, _ := repo.InsertWorkSession(repository.WorkSession{
|
||||
AssignmentID: sjogvurWeb.ID,
|
||||
TaskID: tSjogvur2.ID,
|
||||
TaskDescription: desc,
|
||||
StartTime: runningStart,
|
||||
EndTime: nil,
|
||||
})
|
||||
|
||||
if runningSession != nil {
|
||||
updated := repository.WorkSession{
|
||||
ID: runningSession.ID,
|
||||
AssignmentID: runningSession.AssignmentID,
|
||||
TaskID: runningSession.TaskID,
|
||||
TaskDescription: desc,
|
||||
StartTime: runningSession.StartTime,
|
||||
EndTime: runningSession.EndTime,
|
||||
DurationMinutes: runningSession.DurationMinutes,
|
||||
}
|
||||
repo.UpdateWorkSession(runningSession.ID, updated)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -27,7 +27,7 @@ func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
s.mu.Lock()
|
||||
// Web-only POC: settings edits stay in memory only and are reset with the
|
||||
// rest of the POC state every 10 minutes. config.json is never rewritten.
|
||||
// rest of the POC state every 20 minutes. config.json is never rewritten.
|
||||
s.Cfg.CompanyName = r.FormValue("company_name")
|
||||
s.Cfg.CompanyAddress = r.FormValue("company_address")
|
||||
s.Cfg.CompanyPhone = r.FormValue("company_phone")
|
||||
|
||||
+478
-115
@@ -1,129 +1,317 @@
|
||||
/* zeitkort-web — deliberately plain. Light + dark (follows the browser). */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@300;400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #fff;
|
||||
--fg: #000;
|
||||
--muted: #777;
|
||||
--border: #999;
|
||||
--border-soft: #ccc;
|
||||
--dotted: #ccc;
|
||||
--field-bg: #fff;
|
||||
--field-border: #888;
|
||||
--btn-bg: #eee;
|
||||
--btn-bg-hover: #ddd;
|
||||
--btn-border: #555;
|
||||
--header-border: #000;
|
||||
--th-bg: #f0f0f0;
|
||||
--row-active: #ffd;
|
||||
--link: #003;
|
||||
--accent-bg: #e3ecff;
|
||||
--accent-border: #357;
|
||||
--notice-fg: #060; --notice-bg: #efe; --notice-border: #060;
|
||||
--error-fg: #a00; --error-bg: #fee; --error-border: #a00;
|
||||
--go-bg: #e7f5e7; --go-border: #2a8a2a; --go-bg-hover: #d6eed6;
|
||||
--stop-bg: #fbe4e4; --stop-border: #a00; --stop-bg-hover: #f6d2d2;
|
||||
--card-bg: #fff; --card-border: #a00;
|
||||
--danger-btn-bg: #fee; --danger-btn-bg-hover: #fdd;
|
||||
--backdrop: rgba(0, 0, 0, .35);
|
||||
--shadow: rgba(0, 0, 0, .4);
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--bg: #f4f1ec;
|
||||
--bg-panel: rgba(255, 255, 255, 0.85);
|
||||
--nav-bg: rgba(255, 253, 250, 0.92);
|
||||
|
||||
--fg: #1a1d22;
|
||||
--muted: #595f68;
|
||||
--dim: #9299a2;
|
||||
|
||||
--accent: #164f75;
|
||||
--link: #0f527d;
|
||||
--accent-bg: rgba(22, 79, 117, 0.07);
|
||||
--accent-border: #164f75;
|
||||
|
||||
--border: rgba(26, 29, 34, 0.1);
|
||||
--border-soft: rgba(26, 29, 34, 0.06);
|
||||
--dotted: rgba(26, 29, 34, 0.12);
|
||||
|
||||
--field-bg: rgba(255, 253, 250, 0.6);
|
||||
--field-border: rgba(26, 29, 34, 0.18);
|
||||
|
||||
--btn-bg: rgba(26, 29, 34, 0.05);
|
||||
--btn-bg-hover: rgba(26, 29, 34, 0.09);
|
||||
--btn-border: rgba(26, 29, 34, 0.18);
|
||||
|
||||
--header-border: rgba(26, 29, 34, 0.12);
|
||||
--th-bg: rgba(26, 29, 34, 0.04);
|
||||
--row-active: rgba(22, 79, 117, 0.06);
|
||||
|
||||
--notice-fg: #2a6e2a; --notice-bg: rgba(42, 110, 42, 0.06); --notice-border: rgba(42, 110, 42, 0.25);
|
||||
--error-fg: #a03030; --error-bg: rgba(160, 48, 48, 0.06); --error-border: rgba(160, 48, 48, 0.25);
|
||||
--go-bg: rgba(42, 110, 42, 0.06); --go-border: rgba(42, 110, 42, 0.3); --go-bg-hover: rgba(42, 110, 42, 0.1);
|
||||
--stop-bg: rgba(160, 48, 48, 0.06); --stop-border: rgba(160, 48, 48, 0.3); --stop-bg-hover: rgba(160, 48, 48, 0.1);
|
||||
--card-bg: #fff; --card-border: rgba(160, 48, 48, 0.3);
|
||||
--danger-btn-bg: rgba(160, 48, 48, 0.06); --danger-btn-bg-hover: rgba(160, 48, 48, 0.12);
|
||||
--backdrop: rgba(0, 0, 0, 0.3);
|
||||
--shadow: rgba(0, 0, 0, 0.12);
|
||||
|
||||
--toggle-bg: linear-gradient(135deg, #24273a 0%, #8aadf4 100%);
|
||||
--toggle-border: rgba(26, 29, 34, 0.2);
|
||||
--toggle-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1d1f21;
|
||||
--fg: #e6e6e6;
|
||||
--muted: #9aa0a6;
|
||||
--border: #555;
|
||||
--border-soft: #3a3d40;
|
||||
--dotted: #3a3d40;
|
||||
--field-bg: #2a2d30;
|
||||
--field-border: #5a5e62;
|
||||
--btn-bg: #34383c;
|
||||
--btn-bg-hover: #3f4348;
|
||||
--btn-border: #6a6e72;
|
||||
--header-border: #6a6e72;
|
||||
--th-bg: #2a2d30;
|
||||
--row-active: #3a3a20;
|
||||
--link: #9bbcff;
|
||||
--accent-bg: #21344f;
|
||||
--accent-border: #5a86c0;
|
||||
--notice-fg: #8fe08f; --notice-bg: #142a14; --notice-border: #3f7a3f;
|
||||
--error-fg: #ff9b9b; --error-bg: #3a1616; --error-border: #a85050;
|
||||
--go-bg: #1f3a1f; --go-border: #4caf4c; --go-bg-hover: #264826;
|
||||
--stop-bg: #3a1a1a; --stop-border: #cc5555; --stop-bg-hover: #4a2222;
|
||||
--card-bg: #26292c; --card-border: #cc5555;
|
||||
--danger-btn-bg: #3a1a1a; --danger-btn-bg-hover: #4a2222;
|
||||
--backdrop: rgba(0, 0, 0, .6);
|
||||
--shadow: rgba(0, 0, 0, .75);
|
||||
}
|
||||
[data-theme="macchiato"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg: #181926;
|
||||
--bg-panel: #24273a;
|
||||
--nav-bg: rgba(36, 39, 58, 0.65);
|
||||
|
||||
--fg: #cad3f5;
|
||||
--muted: #a5adcb;
|
||||
--dim: #8087a2;
|
||||
|
||||
--accent: #8aadf4;
|
||||
--link: #8aadf4;
|
||||
--accent-bg: rgba(138, 173, 244, 0.06);
|
||||
--accent-border: #8aadf4;
|
||||
|
||||
--border: rgba(165, 173, 203, 0.15);
|
||||
--border-soft: rgba(165, 173, 203, 0.08);
|
||||
--dotted: rgba(165, 173, 203, 0.12);
|
||||
|
||||
--field-bg: rgba(138, 173, 244, 0.04);
|
||||
--field-border: rgba(165, 173, 203, 0.18);
|
||||
|
||||
--btn-bg: rgba(138, 173, 244, 0.06);
|
||||
--btn-bg-hover: rgba(138, 173, 244, 0.1);
|
||||
--btn-border: rgba(165, 173, 203, 0.18);
|
||||
|
||||
--header-border: rgba(165, 173, 203, 0.15);
|
||||
--th-bg: rgba(138, 173, 244, 0.04);
|
||||
--row-active: rgba(138, 173, 244, 0.06);
|
||||
|
||||
--notice-fg: #a6da95; --notice-bg: rgba(166, 218, 149, 0.06); --notice-border: rgba(166, 218, 149, 0.25);
|
||||
--error-fg: #f38ba8; --error-bg: rgba(243, 139, 168, 0.06); --error-border: rgba(243, 139, 168, 0.2);
|
||||
--go-bg: rgba(166, 218, 149, 0.08); --go-border: rgba(166, 218, 149, 0.3); --go-bg-hover: rgba(166, 218, 149, 0.14);
|
||||
--stop-bg: rgba(243, 139, 168, 0.08); --stop-border: rgba(243, 139, 168, 0.3); --stop-bg-hover: rgba(243, 139, 168, 0.14);
|
||||
--card-bg: #24273a; --card-border: rgba(243, 139, 168, 0.4);
|
||||
--danger-btn-bg: rgba(243, 139, 168, 0.1); --danger-btn-bg-hover: rgba(243, 139, 168, 0.18);
|
||||
--backdrop: rgba(0, 0, 0, 0.55);
|
||||
--shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
--toggle-bg: #24273a;
|
||||
--toggle-border: rgba(138, 173, 244, 0.4);
|
||||
--toggle-shadow: 0 0 10px rgba(138, 173, 244, 0.5), 0 0 25px rgba(138, 173, 244, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="macchiato"] select,
|
||||
[data-theme="macchiato"] option {
|
||||
background: #24273a;
|
||||
color: #cad3f5;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { max-width: 100%; overflow-x: hidden; }
|
||||
body {
|
||||
font-family: -apple-system, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
transition: background 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Long, unbreakable names must never widen the page on mobile. */
|
||||
main { overflow-wrap: break-word; padding: 16px; max-width: 1040px; margin: 0 auto; }
|
||||
main { overflow-wrap: break-word; padding: 16px; max-width: 1040px; margin: 0 auto; width: 100%; flex: 1; }
|
||||
input, select, textarea { max-width: 100%; }
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
/* ---- Header / nav ---- */
|
||||
header {
|
||||
border-bottom: 2px solid var(--header-border);
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
padding: 1rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(12px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(120%);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
.brand { font-weight: bold; font-size: 18px; }
|
||||
nav a { margin-right: 14px; text-decoration: none; color: var(--link); }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
footer { padding: 12px 16px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--border-soft); margin-top: 24px; }
|
||||
.brand { justify-self: center; }
|
||||
nav { justify-self: end; }
|
||||
|
||||
h2 { margin: 0 0 12px; }
|
||||
.back-link {
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--fg);
|
||||
border-color: var(--muted);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
fieldset { border: 1px solid var(--border); margin: 0 0 14px; padding: 10px 12px; min-width: 0; }
|
||||
legend { font-weight: bold; padding: 0 4px; }
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand:hover { color: var(--accent); }
|
||||
|
||||
.brand-sep {
|
||||
font-weight: 300;
|
||||
color: var(--dim);
|
||||
}
|
||||
.brand-sub {
|
||||
font-weight: 400;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-theme="macchiato"] .brand {
|
||||
color: #cad3f5;
|
||||
text-shadow: 0 0 8px rgba(138, 173, 244, 0.25);
|
||||
}
|
||||
|
||||
nav a {
|
||||
margin-right: 0.9rem;
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
nav a:hover { color: var(--fg); border-bottom-color: transparent; }
|
||||
|
||||
nav a.active {
|
||||
color: var(--fg);
|
||||
border-bottom-color: var(--fg);
|
||||
}
|
||||
.offcanvas-links a.active {
|
||||
color: var(--fg);
|
||||
border-bottom-color: var(--fg);
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 0.9rem 1.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.footer-icon {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: none;
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
.footer-icon:hover {
|
||||
color: var(--accent);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.9rem;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
[data-theme="macchiato"] h2 {
|
||||
text-shadow: 0 0 10px rgba(138, 173, 244, 0.2);
|
||||
}
|
||||
|
||||
/* ---- Forms ---- */
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
margin: 0 0 14px;
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
transition: border-color 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
legend {
|
||||
font-weight: 500;
|
||||
padding: 0 6px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
select, input[type=text], input[type=number], input:not([type]), textarea {
|
||||
font: inherit;
|
||||
padding: 3px 5px;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border: 1px solid var(--field-border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
color: var(--fg);
|
||||
transition: border-color 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
select:focus, input[type=text]:focus, input[type=number]:focus, input:not([type]):focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
textarea { width: 100%; max-width: 460px; }
|
||||
label { display: inline-block; }
|
||||
label { display: inline-block; font-size: 0.85rem; }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--btn-border);
|
||||
border-radius: 3px;
|
||||
background: var(--btn-bg);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
button:hover { background: var(--btn-bg-hover); }
|
||||
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.error { color: var(--error-fg); border: 1px solid var(--error-border); padding: 6px 10px; background: var(--error-bg); }
|
||||
.notice { color: var(--notice-fg); border: 1px solid var(--notice-border); padding: 6px 10px; background: var(--notice-bg); }
|
||||
.muted { color: var(--muted); font-size: 0.78rem; }
|
||||
.error { color: var(--error-fg); border: 1px solid var(--error-border); padding: 6px 10px; background: var(--error-bg); border-radius: 3px; }
|
||||
.notice { color: var(--notice-fg); border: 1px solid var(--notice-border); padding: 6px 10px; background: var(--notice-bg); border-radius: 3px; }
|
||||
|
||||
.columns { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.col { flex: 1 1 340px; min-width: 0; }
|
||||
|
||||
.task { padding: 4px 0; border-bottom: 1px dotted var(--dotted); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.task { padding: 5px 0; border-bottom: 1px dotted var(--dotted); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.task .pick { flex: 1 1 140px; min-width: 0; }
|
||||
|
||||
button.go { font-weight: bold; min-width: 84px; }
|
||||
button.go { font-weight: 700; min-width: 84px; letter-spacing: 0.04em; }
|
||||
button.go:not(.stop):not([disabled]) { background: var(--go-bg); border-color: var(--go-border); }
|
||||
button.go:not(.stop):not([disabled]):hover { background: var(--go-bg-hover); }
|
||||
button.go.stop { background: var(--stop-bg); border-color: var(--stop-border); }
|
||||
@@ -134,21 +322,23 @@ button.go[disabled] { opacity: .45; cursor: not-allowed; }
|
||||
fieldset > .inline { margin-top: 10px; }
|
||||
.add-toggle { margin-top: 10px; }
|
||||
|
||||
.clock { font-family: monospace; font-size: 26px; font-weight: bold; margin: 0; }
|
||||
.clock { font-family: 'Noto Sans', sans-serif; font-size: 1.5rem; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||
|
||||
/* Sticky bar shown only while a timer runs. */
|
||||
.running-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
top: 52px;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--go-bg);
|
||||
border: 1px solid var(--go-border);
|
||||
border-radius: 3px;
|
||||
padding: 8px 12px;
|
||||
margin: 0 0 14px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.rb-task { flex: 1 1 auto; }
|
||||
.running-bar .go { margin-left: auto; }
|
||||
@@ -157,33 +347,39 @@ fieldset > .inline { margin-top: 10px; }
|
||||
display: inline-block;
|
||||
min-width: 84px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--go-border);
|
||||
border-radius: 3px;
|
||||
background: var(--go-bg);
|
||||
color: var(--fg);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---- Tables ---- */
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid var(--border-soft); padding: 3px 6px; text-align: left; font-size: 13px; vertical-align: top; }
|
||||
th { background: var(--th-bg); }
|
||||
th, td { border: 1px solid var(--border-soft); padding: 0.3rem 0.5rem; text-align: left; font-size: 0.78rem; vertical-align: top; }
|
||||
th { background: var(--th-bg); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 0.7rem; color: var(--muted); }
|
||||
tr.active { background: var(--row-active); }
|
||||
.row-actions { white-space: nowrap; }
|
||||
.row-actions button { padding: 1px 6px; font-size: 12px; }
|
||||
.row-actions button { padding: 1px 6px; font-size: 0.72rem; }
|
||||
|
||||
.line { display: flex; gap: 6px; margin: 4px 0; flex-wrap: wrap; }
|
||||
.line input { width: 110px; }
|
||||
.line input[name=extra_desc] { width: 220px; }
|
||||
|
||||
/* Two-tap delete confirm — a centered card, always fully on-screen (mobile-safe).
|
||||
No native dialog and no JS, so it works in Brave/Chrome/Safari alike. */
|
||||
/* ---- Two-tap delete confirm ---- */
|
||||
details.confirm { display: inline-block; }
|
||||
details.confirm > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--btn-border);
|
||||
border-radius: 3px;
|
||||
background: var(--btn-bg);
|
||||
color: var(--fg);
|
||||
display: inline-block;
|
||||
@@ -193,7 +389,6 @@ details.confirm > summary::marker { content: ""; }
|
||||
details.confirm > summary:hover { background: var(--btn-bg-hover); }
|
||||
details.confirm[open] > summary { background: var(--stop-bg); border-color: var(--stop-border); }
|
||||
|
||||
/* Dim the page behind the open confirm card. */
|
||||
details.confirm[open]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
@@ -209,71 +404,239 @@ details.confirm[open]::before {
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1000;
|
||||
width: min(360px, 92vw);
|
||||
padding: 16px 18px;
|
||||
padding: 1.2rem 1.4rem;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 3px;
|
||||
background: var(--card-bg);
|
||||
color: var(--fg);
|
||||
white-space: normal;
|
||||
box-shadow: 0 6px 30px var(--shadow);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
box-shadow: 0 8px 30px var(--shadow);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
text-align: left;
|
||||
}
|
||||
.confirm-pop button { margin-top: 12px; margin-right: 6px; }
|
||||
.confirm-pop button:not(.cancel) { border-color: var(--stop-border); background: var(--danger-btn-bg); }
|
||||
.confirm-pop button:not(.cancel):hover { background: var(--danger-btn-bg-hover); }
|
||||
|
||||
.row-actions details.confirm > summary { padding: 1px 6px; font-size: 12px; }
|
||||
.row-actions details.confirm > summary { padding: 1px 6px; font-size: 0.72rem; }
|
||||
|
||||
/* Very visible countdown for the proof-of-concept reset. */
|
||||
/* ---- POC reset banner ---- */
|
||||
.reset-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 80;
|
||||
top: 52px;
|
||||
z-index: 18;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 3px solid var(--error-border);
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--error-border);
|
||||
background: var(--error-bg);
|
||||
color: var(--error-fg);
|
||||
text-align: center;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.reset-label {
|
||||
font-weight: bold;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.reset-countdown {
|
||||
font-family: monospace;
|
||||
font-size: clamp(34px, 8vw, 64px);
|
||||
font-weight: 900;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: clamp(1.5rem, 6vw, 2.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 10px;
|
||||
border: 2px solid currentColor;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.reset-note {
|
||||
flex-basis: 100%;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.reset-imminent .reset-banner {
|
||||
outline: 4px solid var(--error-border);
|
||||
outline-offset: -4px;
|
||||
outline: 2px solid var(--error-border);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.about-card {
|
||||
max-width: 760px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--field-bg);
|
||||
padding: 12px 14px;
|
||||
padding: 1.2rem 1.4rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.about-card p { margin-bottom: 0.6rem; }
|
||||
|
||||
.poc-warning {
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 3px;
|
||||
background: var(--error-bg);
|
||||
color: var(--error-fg);
|
||||
padding: 8px 10px;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
/* ---- Theme toggle ---- */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--toggle-border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 30;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--toggle-shadow);
|
||||
background: var(--toggle-bg);
|
||||
}
|
||||
.theme-toggle:hover { transform: scale(1.08); }
|
||||
|
||||
[data-theme="macchiato"] .theme-toggle {
|
||||
animation: neon-breathe-toggle 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes neon-breathe-toggle {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 10px rgba(138, 173, 244, 0.5),
|
||||
0 0 25px rgba(138, 173, 244, 0.3),
|
||||
0 0 50px rgba(116, 150, 230, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 15px rgba(138, 173, 244, 0.6),
|
||||
0 0 35px rgba(138, 173, 244, 0.4),
|
||||
0 0 70px rgba(116, 150, 230, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hamburger (hidden on desktop) ── */
|
||||
.nav-hamburger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
justify-self: end;
|
||||
}
|
||||
.nav-hamburger span {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 1.5px;
|
||||
background: var(--muted);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.nav-hamburger:hover span { background: var(--fg); }
|
||||
|
||||
/* ── Off-canvas overlay ── */
|
||||
.nav-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 49;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.nav-overlay.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
/* ── Off-canvas panel ── */
|
||||
.nav-offcanvas {
|
||||
position: fixed; top: 0; right: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
background: var(--bg);
|
||||
z-index: 50;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex; flex-direction: column;
|
||||
padding: calc(60px + 2rem) 2rem 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.nav-offcanvas.open { transform: translateX(0); }
|
||||
|
||||
.offcanvas-header {
|
||||
position: absolute; top: 0; left: 0; right: 0;
|
||||
height: 60px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nav-close {
|
||||
background: none; border: none;
|
||||
font-size: 1.3rem; color: var(--muted);
|
||||
cursor: pointer; padding: 0.5rem;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
}
|
||||
.nav-close:hover { color: var(--fg); }
|
||||
|
||||
.offcanvas-links {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 0.15rem; width: 100%;
|
||||
}
|
||||
.offcanvas-links a {
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.offcanvas-links a:first-child { border-top: 1px solid var(--border); }
|
||||
.offcanvas-links a:hover { color: var(--fg); padding-left: 0.5rem; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
header { padding: 0.7rem 0.9rem; gap: 0.6rem; }
|
||||
.back-link { font-size: 0.58rem; padding: 0.25rem 0.4rem; letter-spacing: 0.08em; }
|
||||
.brand { font-size: 0.9rem; }
|
||||
nav a { font-size: 0.62rem; margin-right: 0.6rem; letter-spacing: 0.08em; }
|
||||
footer { font-size: 0.62rem; padding: 0.7rem 0.9rem; }
|
||||
main { padding: 12px; }
|
||||
.theme-toggle { width: 20px; height: 20px; bottom: 1rem; right: 0.9rem; }
|
||||
.reset-banner { top: 44px; }
|
||||
.running-bar { top: 44px; }
|
||||
.nav-desktop { display: none; }
|
||||
.nav-hamburger { display: flex; }
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.clock {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.reset-banner {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
bottom: 1rem;
|
||||
top: auto;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
text-align: left;
|
||||
}
|
||||
.reset-countdown {
|
||||
font-size: 0.9rem;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.reset-note {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// The only JavaScript in the app: tick the live work timer and the visible POC reset countdown.
|
||||
// Everything else is plain HTML + htmx.
|
||||
(function () {
|
||||
var workInterval = null;
|
||||
var resetInterval = null;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p>From here on you can either start a timer for a certain task, or add tasks manually. Everything can be edited or deleted, but not cascading deletes upwards in the hierarchy.</p>
|
||||
<p>When you are done with an assignment for the company, create the invoice. When pressing the button, you can change the basic info about the company, and you can add lines that are not time, but quantity or other measures. For example, 2 pcs. routers at 200 kr a piece. Or whatever you know what I mean. Or like perhaps it is not an hourly rate, but just a one time price for the whole assignment.</p>
|
||||
<br>
|
||||
<p>This dummy version runs in-memory and is reset every 10 minutes (When I run it I have an API to my Nixos home server which uses sqlite as data store).</p>
|
||||
<p>This dummy version runs in-memory and is reset every 20 minutes (When I run it I have an API to my Nixos home server which uses sqlite as data store).</p>
|
||||
<p>It is served via Caddy (I love Caddy) to my FLÓ subdomain.</p>
|
||||
<p>It is one static Go binary, it uses the golang built-in server-rendered HTML templating language, htmx for page updates (Javascript for the dummy timer), embedded static files.</p>
|
||||
<p>I mean, come on, this things ships <strong>as a single binary</strong>, like all of this right? And when I use the API, it is all still a single binary. Could not be easier (I love Go).</p>
|
||||
|
||||
+98
-7
@@ -3,19 +3,51 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>zeitkort</title>
|
||||
<title>zeitkort — FLÓ</title>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var stored = localStorage.getItem('poc-theme');
|
||||
// Changed default from 'tokyo' to 'macchiato'
|
||||
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'macchiato' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<span class="brand">zeitkort</span>
|
||||
<nav>
|
||||
<a href="/">Timer</a>
|
||||
<a hx-get="/settings" hx-target="#main" href="javascript:void(0)">Settings</a>
|
||||
<a hx-get="/about" hx-target="#main" href="javascript:void(0)">About</a>
|
||||
<a href="https://fló.fo" class="back-link">← FLÓ.FO</a>
|
||||
<span class="brand">FLÓ <span class="brand-sep">/</span> <span class="brand-sub">ZEITKORT</span></span>
|
||||
<nav class="nav-desktop">
|
||||
<a href="/">[TIMER]</a>
|
||||
<a hx-get="/settings" hx-target="#main" href="javascript:void(0)">[SETTINGS]</a>
|
||||
<a hx-get="/about" hx-target="#main" href="javascript:void(0)">[ABOUT]</a>
|
||||
</nav>
|
||||
<button id="nav-hamburger" class="nav-hamburger" aria-label="Open menu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Off-canvas mobile menu -->
|
||||
<div id="nav-overlay" class="nav-overlay"></div>
|
||||
<div id="nav-offcanvas" class="nav-offcanvas">
|
||||
<div class="offcanvas-header">
|
||||
<span class="brand">FLÓ <span class="brand-sep">/</span> <span class="brand-sub">ZEITKORT</span></span>
|
||||
<button id="nav-close" class="nav-close" aria-label="Close menu">✕</button>
|
||||
</div>
|
||||
<div class="offcanvas-links">
|
||||
<a href="/">[TIMER]</a>
|
||||
<a hx-get="/settings" hx-target="#main" href="javascript:void(0)">[SETTINGS]</a>
|
||||
<a hx-get="/about" hx-target="#main" href="javascript:void(0)">[ABOUT]</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reset-banner" role="status" aria-live="polite">
|
||||
<span class="reset-label">POC reset in</span>
|
||||
<span id="reset-countdown" class="reset-countdown" data-reset-at="{{rfc3339 .ResetAt}}">--:--</span>
|
||||
@@ -26,9 +58,68 @@
|
||||
{{template "main" .}}
|
||||
</main>
|
||||
|
||||
<footer>zeitkort-web {{.Version}} · web-only POC · resets every 10 minutes</footer>
|
||||
<footer>
|
||||
<span>© <script>document.write(new Date().getFullYear())</script> FLÓ.FO | ALL RIGHTS RESERVED</span>
|
||||
<a href="https://www.facebook.com/people/FL%C3%93/61593006613799/" target="_blank" rel="noopener" class="footer-icon" aria-label="Facebook">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
</a>
|
||||
<a href="https://git.flo.fo/FLO" target="_blank" rel="noopener" class="footer-icon" aria-label="Gitea">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/in/bartal-l%C3%A6arsson-63793895/" target="_blank" rel="noopener" class="footer-icon" aria-label="LinkedIn">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
|
||||
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/timer.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var current = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
var next = current === 'light' ? 'macchiato' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('poc-theme', next);
|
||||
});
|
||||
}
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
||||
if (!localStorage.getItem('poc-theme')) {
|
||||
document.documentElement.setAttribute('data-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'macchiato' : 'light');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var overlay = document.getElementById('nav-overlay');
|
||||
var panel = document.getElementById('nav-offcanvas');
|
||||
var openBtn = document.getElementById('nav-hamburger');
|
||||
var closeBtn = document.getElementById('nav-close');
|
||||
function open() { panel.classList.add('open'); overlay.classList.add('open'); }
|
||||
function close() { panel.classList.remove('open'); overlay.classList.remove('open'); }
|
||||
if (openBtn) openBtn.addEventListener('click', open);
|
||||
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||
if (overlay) overlay.addEventListener('click', close);
|
||||
panel.querySelectorAll('a').forEach(function(a) { a.addEventListener('click', close); });
|
||||
|
||||
document.querySelectorAll('nav a, .offcanvas-links a').forEach(function(a) {
|
||||
a.addEventListener('click', function() {
|
||||
document.querySelectorAll('nav a, .offcanvas-links a').forEach(function(el) {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
var href = a.getAttribute('hx-get') || a.getAttribute('href');
|
||||
document.querySelectorAll('nav a, .offcanvas-links a').forEach(function(el) {
|
||||
var elHref = el.getAttribute('hx-get') || el.getAttribute('href');
|
||||
if (elHref === href) el.classList.add('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user