diff --git a/editor/assets/assets.go b/editor/assets/assets.go new file mode 100644 index 0000000..4c7f344 --- /dev/null +++ b/editor/assets/assets.go @@ -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 +} diff --git a/editor/assets/css/style.css b/editor/assets/css/style.css new file mode 100644 index 0000000..ac0038e --- /dev/null +++ b/editor/assets/css/style.css @@ -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; + } + } + } +} diff --git a/editor/assets/css/tailwind.css b/editor/assets/css/tailwind.css new file mode 100644 index 0000000..c5973e4 --- /dev/null +++ b/editor/assets/css/tailwind.css @@ -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; + } + } + } +} diff --git a/editor/cmd/cmd.go b/editor/cmd/cmd.go new file mode 100644 index 0000000..a99c852 --- /dev/null +++ b/editor/cmd/cmd.go @@ -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) +} diff --git a/editor/router/router.go b/editor/router/router.go new file mode 100644 index 0000000..7f06730 --- /dev/null +++ b/editor/router/router.go @@ -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 +} diff --git a/editor/template/dashboard.html b/editor/template/dashboard.html new file mode 100644 index 0000000..e181fae --- /dev/null +++ b/editor/template/dashboard.html @@ -0,0 +1,2 @@ +{{define "editor-dashboard"}} {{template "layout-base"}} +{{template "layout-base-end"}} {{end}} diff --git a/editor/template/layouts/layout-base.html b/editor/template/layouts/layout-base.html new file mode 100644 index 0000000..e9a4c07 --- /dev/null +++ b/editor/template/layouts/layout-base.html @@ -0,0 +1,19 @@ +{{define "layout-base"}} + + + + + + {{if .Title}} + {{.Title}} + {{end}} + + + + {{end}} {{define "layout-base-end"}} + +{{end}} diff --git a/editor/template/template.go b/editor/template/template.go new file mode 100644 index 0000000..2b7a13e --- /dev/null +++ b/editor/template/template.go @@ -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 + }, + } +)