feat(editor,router,cmd): router and server setup to run editor
This commit is contained in:
13
editor/assets/assets.go
Normal file
13
editor/assets/assets.go
Normal 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
225
editor/assets/css/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
editor/assets/css/tailwind.css
Normal file
22
editor/assets/css/tailwind.css
Normal 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
128
editor/cmd/cmd.go
Normal 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
41
editor/router/router.go
Normal 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
|
||||
}
|
||||
2
editor/template/dashboard.html
Normal file
2
editor/template/dashboard.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{{define "editor-dashboard"}} {{template "layout-base"}}
|
||||
{{template "layout-base-end"}} {{end}}
|
||||
19
editor/template/layouts/layout-base.html
Normal file
19
editor/template/layouts/layout-base.html
Normal 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}}
|
||||
50
editor/template/template.go
Normal file
50
editor/template/template.go
Normal 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
|
||||
},
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user