feat(editor,router,cmd): router and server setup to run editor

This commit is contained in:
Guz
2025-11-20 15:16:23 -03:00
parent 8d75630f5c
commit e438c0c850
8 changed files with 500 additions and 0 deletions

13
editor/assets/assets.go Normal file
View File

@@ -0,0 +1,13 @@
package assets
import (
"embed"
"io/fs"
)
//go:embed css/style.css js/*.js ipub/*.js ipub/*.css
var files embed.FS
func New() fs.FS {
return files
}

225
editor/assets/css/style.css Normal file
View File

@@ -0,0 +1,225 @@
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--color-gray-50: oklch(98.5% 0.002 247.839);
--color-gray-600: oklch(44.6% 0.03 256.802);
--color-gray-700: oklch(37.3% 0.034 259.733);
--color-gray-900: oklch(21% 0.034 264.665);
--spacing: 0.25rem;
--radius-md: 0.375rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden='until-found'])) {
display: none !important;
}
}
@layer utilities {
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.static {
position: static;
}
.contents {
display: contents;
}
.flex {
display: flex;
}
.resize {
resize: both;
}
.flex-col {
flex-direction: column;
}
.bg-gray-900 {
background-color: var(--color-gray-900);
}
.text-gray-50 {
color: var(--color-gray-50);
}
.has-\[\#first-publication\]\:h-full {
&:has(*:is(#first-publication)) {
height: 100%;
}
}
.has-\[\#first-publication\]\:h-svw {
&:has(*:is(#first-publication)) {
height: 100svw;
}
}
}
@layer base {
form {
input {
background-color: var(--color-gray-700);
border-radius: var(--radius-md);
padding-inline-start: calc(var(--spacing) * 2);
&:has(+ button) {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
}
button {
background-color: var(--color-gray-600);
border-radius: var(--radius-md);
padding: 0 calc(var(--spacing) * 2);
input + & {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
}

View File

@@ -0,0 +1,22 @@
@import "tailwindcss";
@layer base {
form {
input {
background-color: var(--color-gray-700);
border-radius: var(--radius-md);
padding-inline-start: --spacing(2);
&:has(+ button) {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
}
button {
background-color: var(--color-gray-600);
border-radius: var(--radius-md);
padding: 0 --spacing(2);
input + & {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
}

128
editor/cmd/cmd.go Normal file
View File

@@ -0,0 +1,128 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/capytal/comicverse/editor/assets"
"code.capytal.cc/capytal/comicverse/editor/router"
"code.capytal.cc/capytal/comicverse/editor/storage"
"code.capytal.cc/capytal/comicverse/editor/template"
"code.capytal.cc/loreddev/x/tinyssert"
)
var (
hostname = flag.String("hostname", "localhost", "Host to listen to")
port = flag.Uint("port", 8080, "Port to be used for the server.")
verbose = flag.Bool("verbose", false, "Print debug information on logs")
dev = flag.Bool("dev", false, "Run the server in debug mode.")
)
var (
storageDir = getEnv("EDITOR_PUBLICATIONS_DIR", ".publications") // TODO: Use XDG_STATE_HOME as default
assetsDir = getEnv("EDITOR_ASSETS_DIR", "assets") // TODO: Use XDG_CONFIG_HOME as default
templatesDir = getEnv("EDITOR_TEMPLATES_DIR", "template") // TODO: Use XDG_CONFIG_HOME as default
)
func getEnv(key string, d string) string {
v := os.Getenv(key)
if v == "" {
return d
}
return v
}
func init() {
flag.Parse()
}
func main() {
ctx := context.Background()
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
assert := tinyssert.New(tinyssert.WithLogger(log))
assets := assets.New()
templater, err := template.New()
if err != nil {
log.Error("Unable to initiate templater due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
if *dev {
assets = os.DirFS(assetsDir)
log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
assert = tinyssert.New(tinyssert.WithPanic(), tinyssert.WithLogger(log.WithGroup("assertions")))
templater, err = template.Dev(os.DirFS(templatesDir))
if err != nil {
log.Error("Unable to initiate dev templater due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
}
err = os.MkdirAll(storageDir, os.ModePerm)
if err != nil {
log.Error("Unable to create storage directory due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
root, err := os.OpenRoot(storageDir)
if err != nil {
log.Error("Unable to open storage directory due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
storage := storage.Newlocal(root, log)
editor := editor.New(storage, log.WithGroup("editor"), assert)
router := router.New(router.Config{
Assets: assets,
Editor: editor,
Templater: templater,
Logger: log.WithGroup("router"),
})
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", *hostname, *port),
Handler: router,
}
c, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Info("Starting application",
slog.String("host", *hostname),
slog.Uint64("port", uint64(*port)),
slog.Bool("verbose", *verbose),
slog.Bool("development", *dev))
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("Failed to start application server", slog.String("error", err.Error()))
os.Exit(1)
}
}()
<-c.Done()
log.Info("Stopping application gracefully")
if err := srv.Shutdown(c); err != nil {
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
os.Exit(1)
}
log.Info("FINAL")
os.Exit(0)
}

41
editor/router/router.go Normal file
View File

@@ -0,0 +1,41 @@
package router
import (
"io/fs"
"log/slog"
"net/http"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/loreddev/smalltrip"
"code.capytal.cc/loreddev/smalltrip/middleware"
"code.capytal.cc/loreddev/smalltrip/multiplexer"
"code.capytal.cc/loreddev/x/xtemplate"
)
func New(cfg Config) http.Handler {
log := cfg.Logger
mux := multiplexer.New()
mux = multiplexer.WithFormMethod(mux, "x-method")
mux = multiplexer.WithPatternRules(mux,
multiplexer.EnsureMethod(),
multiplexer.EnsureTrailingSlash(),
multiplexer.EnsureStrictEnd(),
)
r := smalltrip.NewRouter(
smalltrip.WithMultiplexer(mux),
smalltrip.WithLogger(log.WithGroup("router")),
)
r.Use(middleware.Logger(log.WithGroup("requests")))
return r
}
type Config struct {
Assets fs.FS
Editor *editor.Editor
Templater xtemplate.Templater
Logger *slog.Logger
}

View File

@@ -0,0 +1,2 @@
{{define "editor-dashboard"}} {{template "layout-base"}}
{{template "layout-base-end"}} {{end}}

View File

@@ -0,0 +1,19 @@
{{define "layout-base"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{if .Title}}
<title>{{.Title}}</title>
{{end}}
<link href="/assets/css/style.css" rel="stylesheet" />
<script
src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.js"
integrity="sha384-oeUn82QNXPuVkGCkcrInrS1twIxKhkZiFfr2TdiuObZ3n3yIeMiqcRzkIcguaof1"
crossorigin="anonymous"
></script>
</head>
{{end}} {{define "layout-base-end"}}
</html>
{{end}}

View File

@@ -0,0 +1,50 @@
package template
import (
"embed"
"errors"
"fmt"
"html/template"
"io/fs"
"code.capytal.cc/loreddev/x/xtemplate"
)
func New() (xtemplate.Template, error) {
return xtemplate.New[template.Template]("template").
Funcs(functions).
ParseFS(embedded, patterns...)
}
//go:embed *.html layouts/*.html
var embedded embed.FS
func Dev(dir fs.FS) (xtemplate.Template, error) {
return xtemplate.NewHot[template.Template]("template").
Funcs(functions).
ParseFS(dir, patterns...)
}
var (
patterns = []string{"*.html", "layouts/*.html"}
functions = template.FuncMap{
"args": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, errors.New("misaligned map in template arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("cannot use type %T as map key", pairs[i])
}
m[key] = pairs[i+1]
}
return m, nil
},
}
)