initial webapp
This commit is contained in:
1 parent
6c989c338c
commit
d32f2b3a66
18 files changed
+1145
-16
No files matched your search
@@ -1,52 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
//go:embed translations/*.json
|
||||
var translationsFS embed.FS
|
||||
|
||||
type Localizer struct {
|
||||
*i18n.Localizer
|
||||
}
|
||||
|
||||
func NewLocalizer(lang string) (*Localizer, error) {
|
||||
bundle := i18n.NewBundle(language.English)
|
||||
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
|
||||
_, err := bundle.LoadMessageFileFS(translationsFS, "translations/en.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading english messages: %w", err)
|
||||
}
|
||||
|
||||
_, err = bundle.LoadMessageFileFS(translationsFS, "translations/pt.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading portuguese messages: %w", err)
|
||||
}
|
||||
|
||||
localizer := i18n.NewLocalizer(bundle, lang)
|
||||
|
||||
return &Localizer{
|
||||
Localizer: localizer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t Localizer) Translate(key string, count int, values map[string]any) string {
|
||||
txt, err := t.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
TemplateData: values,
|
||||
PluralCount: count,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to translate message", slog.Any("err", err))
|
||||
return "<ERROR>"
|
||||
}
|
||||
return txt
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewLocalizer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lang string
|
||||
}{
|
||||
{"english", "en"},
|
||||
{"portuguese", "pt"},
|
||||
{"english with region", "en-US"},
|
||||
{"portuguese with region", "pt-BR"},
|
||||
{"unknown language falls back to default", "!!"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewLocalizer(tt.lang)
|
||||
if err != nil {
|
||||
t.Fatalf("want success call but failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||
"github.com/nmoniz/any2anexoj/internal/prettyprinter"
|
||||
"github.com/nmoniz/any2anexoj/internal/trading212"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/text/language"
|
||||
@@ -100,11 +101,11 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
||||
case "csv":
|
||||
return NewCSVWriter(stdout).Render(writer)
|
||||
case "table":
|
||||
loc, err := NewLocalizer(*lang)
|
||||
loc, err := prettyprinter.NewLocalizer(*lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create localizer: %w", err)
|
||||
}
|
||||
NewPrettyPrinter(stdout, loc).Render(writer)
|
||||
prettyprinter.NewPrettyPrinter(stdout, loc).Render(writer)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported format %q: must be table or csv", *format)
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/biter777/countries"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/jedib0t/go-pretty/v6/text"
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
)
|
||||
|
||||
// PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each
|
||||
// ReportItem received.
|
||||
type PrettyPrinter struct {
|
||||
table table.Writer
|
||||
output io.Writer
|
||||
translator Translator
|
||||
}
|
||||
|
||||
type Translator interface {
|
||||
Translate(key string, count int, values map[string]any) string
|
||||
}
|
||||
|
||||
func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter {
|
||||
tw := table.NewWriter()
|
||||
tw.SetOutputMirror(w)
|
||||
tw.SetAutoIndex(true)
|
||||
tw.SetStyle(table.StyleLight)
|
||||
tw.SetColumnConfigs([]table.ColumnConfig{
|
||||
colCountry(1),
|
||||
colOther(2),
|
||||
colOther(3),
|
||||
colOther(4),
|
||||
colOther(5),
|
||||
colEuros(6),
|
||||
colOther(7),
|
||||
colOther(8),
|
||||
colOther(9),
|
||||
colEuros(10),
|
||||
colEuros(11),
|
||||
colEuros(12),
|
||||
colCountry(13),
|
||||
})
|
||||
|
||||
return &PrettyPrinter{
|
||||
table: tw,
|
||||
output: w,
|
||||
translator: tr,
|
||||
}
|
||||
}
|
||||
|
||||
func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
|
||||
realizationTxt := pp.translator.Translate("realization", 1, nil)
|
||||
acquisitionTxt := pp.translator.Translate("acquisition", 1, nil)
|
||||
yearTxt := pp.translator.Translate("year", 1, nil)
|
||||
monthTxt := pp.translator.Translate("month", 1, nil)
|
||||
dayTxt := pp.translator.Translate("day", 1, nil)
|
||||
valorTxt := pp.translator.Translate("value", 1, nil)
|
||||
|
||||
pp.table.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
|
||||
pp.table.AppendHeader(table.Row{
|
||||
pp.translator.Translate("source_country", 1, nil), pp.translator.Translate("code", 1, nil),
|
||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||
pp.translator.Translate("expenses", 2, nil), pp.translator.Translate("foreign_tax_paid", 1, nil), pp.translator.Translate("counter_country", 1, nil),
|
||||
})
|
||||
|
||||
for ri := range aw.Iter() {
|
||||
pp.table.AppendRow(table.Row{
|
||||
ri.AssetCountry, ri.Nature,
|
||||
ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2),
|
||||
ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2),
|
||||
ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry,
|
||||
})
|
||||
}
|
||||
|
||||
pp.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
|
||||
pp.table.Render()
|
||||
}
|
||||
|
||||
func colEuros(n int) table.ColumnConfig {
|
||||
return table.ColumnConfig{
|
||||
Number: n,
|
||||
Align: text.AlignRight,
|
||||
AlignFooter: text.AlignRight,
|
||||
AlignHeader: text.AlignRight,
|
||||
WidthMin: 12,
|
||||
WidthMax: 15,
|
||||
Transformer: func(val any) string {
|
||||
return fmt.Sprintf("%v €", val)
|
||||
},
|
||||
TransformerFooter: func(val any) string {
|
||||
return fmt.Sprintf("%v €", val)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func colOther(n int) table.ColumnConfig {
|
||||
return table.ColumnConfig{
|
||||
Number: n,
|
||||
Align: text.AlignLeft,
|
||||
AlignFooter: text.AlignLeft,
|
||||
AlignHeader: text.AlignLeft,
|
||||
WidthMax: 12,
|
||||
}
|
||||
}
|
||||
|
||||
func colCountry(n int) table.ColumnConfig {
|
||||
return table.ColumnConfig{
|
||||
Number: n,
|
||||
Align: text.AlignLeft,
|
||||
AlignFooter: text.AlignLeft,
|
||||
AlignHeader: text.AlignLeft,
|
||||
WidthMax: 24,
|
||||
WidthMaxEnforcer: text.Trim,
|
||||
Transformer: func(val any) string {
|
||||
countryCode := val.(int64)
|
||||
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
func TestPrettyPrinter_Render(t *testing.T) {
|
||||
// Create test data
|
||||
aw := internal.NewAggregatorWriter()
|
||||
ctx := t.Context()
|
||||
|
||||
// Add some sample report items
|
||||
err := aw.Write(ctx, internal.ReportItem{
|
||||
Symbol: "AAPL",
|
||||
Nature: internal.NatureG01,
|
||||
BrokerCountry: 826, // United Kingdom
|
||||
AssetCountry: 840, // United States
|
||||
BuyValue: decimal.NewFromFloat(100.50),
|
||||
BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||
SellValue: decimal.NewFromFloat(150.75),
|
||||
SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
|
||||
Fees: decimal.NewFromFloat(2.50),
|
||||
Taxes: decimal.NewFromFloat(5.00),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write first report item: %v", err)
|
||||
}
|
||||
|
||||
err = aw.Write(ctx, internal.ReportItem{
|
||||
Symbol: "GOOGL",
|
||||
Nature: internal.NatureG20,
|
||||
BrokerCountry: 826, // United Kingdom
|
||||
AssetCountry: 840, // United States
|
||||
BuyValue: decimal.NewFromFloat(200.00),
|
||||
BuyTimestamp: time.Date(2023, 3, 10, 0, 0, 0, 0, time.UTC),
|
||||
SellValue: decimal.NewFromFloat(225.50),
|
||||
SellTimestamp: time.Date(2023, 9, 5, 0, 0, 0, 0, time.UTC),
|
||||
Fees: decimal.NewFromFloat(3.00),
|
||||
Taxes: decimal.NewFromFloat(7.50),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write second report item: %v", err)
|
||||
}
|
||||
|
||||
// Create English localizer
|
||||
localizer, err := NewLocalizer("en")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create localizer: %v", err)
|
||||
}
|
||||
|
||||
// Create pretty printer with buffer
|
||||
var buf bytes.Buffer
|
||||
pp := NewPrettyPrinter(&buf, localizer)
|
||||
|
||||
// Render the table
|
||||
pp.Render(aw)
|
||||
|
||||
// Get the output
|
||||
got := buf.String()
|
||||
|
||||
// Expected output
|
||||
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
|
||||
│ │ │ REALIZATION │ ACQUISITION │ │
|
||||
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND CH │ TAX PAID ABROAD │ COUNTER COUNTRY │
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ ARGES │ │ │
|
||||
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
|
||||
│ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │
|
||||
│ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │
|
||||
├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
|
||||
│ │ SUM │ 376.25 € │ │ 300.5 € │ 5.5 € │ 12.5 € │ │
|
||||
└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘
|
||||
`
|
||||
|
||||
// Compare output
|
||||
if got != want {
|
||||
t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"realization": {
|
||||
"one": "Realization",
|
||||
"other": "Realizations"
|
||||
},
|
||||
"acquisition": {
|
||||
"one": "Acquisition",
|
||||
"other": "Acquisitions"
|
||||
},
|
||||
"source_country": {
|
||||
"one": "Source country",
|
||||
"other": "Source countries"
|
||||
},
|
||||
"counter_country": {
|
||||
"one": "Counter country",
|
||||
"other": "Counter countries"
|
||||
},
|
||||
"year": {
|
||||
"one": "Year",
|
||||
"other": "Years"
|
||||
},
|
||||
"month": {
|
||||
"one": "Month",
|
||||
"other": "Months"
|
||||
},
|
||||
"day": {
|
||||
"one": "Day",
|
||||
"other": "Days"
|
||||
},
|
||||
"value": {
|
||||
"one": "Value",
|
||||
"other": "Values"
|
||||
},
|
||||
"code": {
|
||||
"one": "Code",
|
||||
"other": "Codes"
|
||||
},
|
||||
"expenses": {
|
||||
"one": "Expense and charge",
|
||||
"other": "Expenses and charges"
|
||||
},
|
||||
"foreign_tax_paid": {
|
||||
"one": "Tax paid abroad",
|
||||
"other": "Taxes paid abroad"
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"realization": {
|
||||
"one": "Realização",
|
||||
"other": "Realizações"
|
||||
},
|
||||
"acquisition": {
|
||||
"one": "Aquisição",
|
||||
"other": "Aquisições"
|
||||
},
|
||||
"source_country": {
|
||||
"one": "País da fonte",
|
||||
"other": "Países da fonte"
|
||||
},
|
||||
"counter_country": {
|
||||
"one": "País da contraparte",
|
||||
"other": "Países da contraparte"
|
||||
},
|
||||
"year": {
|
||||
"one": "Ano",
|
||||
"other": "Anos"
|
||||
},
|
||||
"month": {
|
||||
"one": "Mês",
|
||||
"other": "Meses"
|
||||
},
|
||||
"day": {
|
||||
"one": "Dia",
|
||||
"other": "Dias"
|
||||
},
|
||||
"value": {
|
||||
"one": "Valor",
|
||||
"other": "Valores"
|
||||
},
|
||||
"code": {
|
||||
"one": "Código",
|
||||
"other": "Códigos"
|
||||
},
|
||||
"expenses": {
|
||||
"one": "Despesa e encargo",
|
||||
"other": "Despesas e encargos"
|
||||
},
|
||||
"foreign_tax_paid": {
|
||||
"one": "Imposto pago no estrangeiro",
|
||||
"other": "Impostos pagos no estrangeiro"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user