initial webapp

This commit is contained in:
natercio committed 2026-09-28 21:46:54 +01:00
1 parent 6c989c338c
commit d32f2b3a66
18 files changed
+1145 -16

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"embed"
"errors"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/nmoniz/any2anexoj/internal/web"
)
//go:embed all:static
var staticFS embed.FS
func main() {
if err := run(); err != nil {
slog.Error("any2anexoj-web: fatal error", slog.Any("err", err))
os.Exit(1)
}
}
// run wires up the server, installs signal-driven cancellation, and blocks on
// the HTTP server until it stops. It is split out from main so tests can drive
// the same code paths if needed.
func run() error {
addr := os.Getenv("PORT")
if addr == "" {
addr = ":8080"
}
// A bare "8080" value is friendlier than the net.Listen-style ":8080".
if addr[0] != ':' {
addr = ":" + addr
}
// Sub-mount the embedded FS so the static subtree is rooted at "" inside
// the web package (matches /static/...).
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
return err
}
state := web.NewState()
srv, err := web.NewServer(state, staticSub)
if err != nil {
return err
}
httpServer := &http.Server{
Addr: addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt, syscall.SIGTERM)
defer cancel()
errCh := make(chan error, 1)
go func() {
slog.Info("any2anexoj-web: listening", slog.String("addr", addr))
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case <-ctx.Done():
slog.Info("any2anexoj-web: shutting down")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return err
}
return nil
case err := <-errCh:
return err
}
}
+108
View File
@@ -0,0 +1,108 @@
(function () {
"use strict";
var UPLOAD_URL = "/api/upload";
function $(id) {
return document.getElementById(id);
}
function setStatus(message) {
var status = $("status");
status.textContent = message || "";
}
function setError(message) {
var error = $("error");
error.textContent = message || "";
}
function clearResult() {
var result = $("result");
var rowsInfo = $("rows-info");
var tableOutput = $("table-output");
rowsInfo.textContent = "";
tableOutput.textContent = "";
result.hidden = true;
}
function showResult(rows, table) {
var result = $("result");
var rowsInfo = $("rows-info");
var tableOutput = $("table-output");
rowsInfo.textContent = "Rows: " + rows;
tableOutput.textContent = table || "";
result.hidden = false;
}
function uploadFile(file) {
var formData = new FormData();
formData.append("csv", file);
return fetch(UPLOAD_URL, {
method: "POST",
body: formData
}).then(function (response) {
return response.json().then(function (data) {
return { ok: response.ok, status: response.status, data: data };
});
});
}
function handleSubmit(event) {
event.preventDefault();
setError("");
clearResult();
var fileInput = $("csv");
if (!fileInput.files || fileInput.files.length === 0) {
setError("Please choose a CSV file before uploading.");
return;
}
var file = fileInput.files[0];
var button = $("upload-button");
button.disabled = true;
setStatus("Uploading " + file.name + "...");
uploadFile(file).then(function (result) {
button.disabled = false;
if (!result.ok) {
setStatus("");
var serverMsg = result.data && result.data.error ? result.data.error : "";
var msg = "Upload failed (HTTP " + result.status + ").";
if (serverMsg) {
msg += " " + serverMsg;
}
setError(msg);
return;
}
if (result.data && typeof result.data.error === "string" && result.data.error.length > 0) {
setStatus("");
setError(result.data.error);
return;
}
var table = result.data && typeof result.data.table === "string" ? result.data.table : "";
var rows = result.data && typeof result.data.rows === "number" ? result.data.rows : 0;
setStatus("Upload complete.");
showResult(rows, table);
}).catch(function (err) {
button.disabled = false;
setStatus("");
var msg = err && err.message ? err.message : String(err);
setError("Network error while uploading: " + msg);
});
}
document.addEventListener("DOMContentLoaded", function () {
var form = $("upload-form");
if (form) {
form.addEventListener("submit", handleSubmit);
}
});
})();
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>any2anexoj web</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<h1>any2anexoj web</h1>
<p class="lede">Upload a Trading212-style CSV and view the rendered tax report.</p>
<form id="upload-form" class="upload-form" novalidate>
<label class="file-label" for="csv">Choose CSV file</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv">
<button type="submit" id="upload-button">Upload</button>
</form>
<div id="status" class="status" role="status" aria-live="polite"></div>
<div id="error" class="error" role="alert" aria-live="assertive"></div>
<section id="result" class="result" hidden>
<h2 id="result-heading">Result</h2>
<p id="rows-info" class="rows-info"></p>
<pre id="table-output" class="table-output"></pre>
</section>
</main>
<script src="/static/app.js" defer></script>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
:root {
--bg: #fafafa;
--fg: #1f2328;
--muted: #57606a;
--accent: #0969da;
--accent-hover: #0860c7;
--border: #d0d7de;
--error-bg: #ffebe9;
--error-fg: #82071e;
--status-fg: #1f2328;
--code-bg: #f6f8fa;
--code-fg: #1f2328;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background-color: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
h1 {
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 600;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.25rem;
font-weight: 600;
}
.lede {
margin: 0 0 2rem;
color: var(--muted);
}
.upload-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background-color: #ffffff;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 1rem;
}
.file-label {
font-weight: 500;
}
input[type="file"] {
font: inherit;
flex: 1 1 240px;
min-width: 0;
}
button {
font: inherit;
font-weight: 500;
padding: 0.5rem 1rem;
background-color: var(--accent);
color: #ffffff;
border: 1px solid var(--accent);
border-radius: 6px;
cursor: pointer;
}
button:hover:not(:disabled) {
background-color: var(--accent-hover);
border-color: var(--accent-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.status {
margin: 0.5rem 0 0;
color: var(--status-fg);
min-height: 1.25rem;
font-size: 0.95rem;
}
.error {
margin: 0.5rem 0 1rem;
padding: 0.75rem 1rem;
background-color: var(--error-bg);
color: var(--error-fg);
border: 1px solid #ffcecb;
border-radius: 6px;
font-size: 0.95rem;
display: none;
white-space: pre-wrap;
}
.error:not(:empty) {
display: block;
}
.result {
margin-top: 1.5rem;
}
.result[hidden] {
display: none;
}
.rows-info {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 0.95rem;
}
.table-output {
margin: 0;
padding: 1rem;
background-color: var(--code-bg);
color: var(--code-fg);
border: 1px solid var(--border);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 0.85rem;
line-height: 1.4;
overflow-x: auto;
overflow-y: visible;
white-space: pre;
max-width: 100%;
}
@media (max-width: 600px) {
.container {
padding: 1.25rem 1rem 3rem;
}
h1 {
font-size: 1.5rem;
}
.upload-form {
flex-direction: column;
align-items: stretch;
}
button {
width: 100%;
}
}