134 Commits

Author SHA1 Message Date
41a764939b feat: landing page template 2025-06-09 19:21:29 -03:00
dc61ed91d0 feat: update assertions contructor function call 2025-06-09 19:21:19 -03:00
3f767299e2 chore: new debugger make job 2025-06-09 19:20:46 -03:00
d5f13b563e chore: ignore air's tmp directory 2025-06-09 19:20:31 -03:00
7308097c61 chore(service): delete old all-service service 2025-06-09 19:20:06 -03:00
e3ce651288 refactor(router): rename c receiver to ctrl 2025-06-09 19:19:27 -03:00
0e7198f918 feat(router): set session token cookie 2025-06-09 19:19:00 -03:00
f4a971bdae feat(router): show landing page if user is not logged in 2025-06-09 19:18:29 -03:00
8403459cc8 fix(repo): send error value on user insert query exec 2025-06-09 19:17:26 -03:00
4e90fa0063 refactor(repo): rename repositoryDateFormat to dateFormat 2025-06-09 19:16:40 -03:00
f622f774e4 refactor: move shared variables to repository.go file 2025-06-09 19:14:28 -03:00
29f1e8cc8a chore: use fortify for hardeningDisable 2025-06-06 16:36:12 -03:00
0cea250fa4 chore: set environment variables on direnv enter 2025-06-06 16:35:53 -03:00
c3a0be5ec5 feat(templates): register page and form 2025-06-06 16:35:23 -03:00
72b884c2b3 feat(templates): login page and form 2025-06-06 16:35:16 -03:00
d38097a616 feat(router,users): register method for creating a new user 2025-06-06 16:34:59 -03:00
f7396dc12b feat(router,users): return token cookie on login 2025-06-06 16:34:41 -03:00
149823a5fc fix(router,users): correct username form value name on error 2025-06-06 16:34:16 -03:00
56e2214311 feat(router): handle /login and /register routes 2025-06-06 16:33:29 -03:00
a52caf6580 feat(router): provide UserService to router 2025-06-06 16:32:49 -03:00
30eb1a0065 feat(user,service): return signed token of user 2025-06-06 16:32:02 -03:00
106c612e63 feat(user,service): return error on incorect construct parameter 2025-06-06 16:31:31 -03:00
06807b0623 chore(router,service): remove editor and projects endpoint and services
They will be reimplemented later
2025-06-06 16:30:50 -03:00
12844eafee chore: format launch dev debug profile 2025-06-06 16:29:41 -03:00
d5668af2df fix(repo,users): incorrect syntax for columns in select 2025-06-06 16:29:25 -03:00
4bb32f9757 feat(repo,users): add assertions check for struct values 2025-06-06 16:29:11 -03:00
52ac9ed3bc fix(repo,users): missing context value on struct initiation 2025-06-06 16:28:45 -03:00
2bce92e51c fix(repo,users): trailing comma in create table query 2025-06-06 16:28:11 -03:00
28ed7379de feat: user controller 2025-05-30 18:05:40 -03:00
16322b3afd revert: remove database abstraction 2025-05-30 18:05:24 -03:00
5fbe9cd1ad chore: update submodule 2025-05-30 18:05:01 -03:00
f7f2a7fbb8 chore: update deps 2025-05-30 18:04:46 -03:00
b29bfdd1df feat(templates): login page 2025-05-30 18:04:39 -03:00
deaf9089b2 feat(users): init token service 2025-05-30 18:04:28 -03:00
ffad82b32c feat(users): user service 2025-05-30 18:04:16 -03:00
dbf30a9908 feat(users): user repository 2025-05-30 18:03:56 -03:00
acda6dbd24 feat(ast,ipub): remove marshalling and unmarshalling logic from ast 2025-05-26 09:28:20 -03:00
a4fc9176cd feat(attr,ipub): small mock test to test unmarshalling and marshalling 2025-05-22 11:10:38 -03:00
9ecacc3808 feat(attr,ipub): ElementChildren to provide a universal unmarshalling of child elements 2025-05-22 11:09:58 -03:00
7f6f9f7682 feat(attr,ipub): Element interface and ElementKind to prepare unmarshalling of un-structured childre 2025-05-22 11:09:15 -03:00
884133941f feat(attr,ipub): attr package to add structured typing for XML Attributes 2025-05-22 11:05:06 -03:00
c05445f702 feat(element,ipub): new element package to take care of XML Marshalling and Unmarshalling 2025-05-22 11:03:43 -03:00
1466c35e39 chore(ipub): small mock test for unmarshalling 2025-05-20 10:12:48 -03:00
1ade2d8f63 chore(ipub): small mock test for marshalling 2025-05-20 10:12:37 -03:00
eb72bab886 feat(ipub,ast): image element 2025-05-20 10:12:02 -03:00
294513a772 feat(ipub,ast)!: BaseElement marshaller 2025-05-20 10:11:27 -03:00
87e7a74dd3 feat(ipub,ast): ElementKind xml.MarshallerAttr and xml.UnmarshallerAttr implementations 2025-05-20 10:08:10 -03:00
f7704b4f18 feat(ipub,ast): Name() method to determina XML element/tag name 2025-05-20 10:05:06 -03:00
b1f6bde29f feat(ipub,ast): xml.Unmarshaller implementation for Elements 2025-05-16 15:17:56 -03:00
fbe01ad098 feat(ipub,ast): Content Element definition 2025-05-16 15:14:02 -03:00
50b387ccf2 feat(ipub,ast): Body Element definition 2025-05-16 15:13:38 -03:00
f1912240a0 feat(ast): ElementKind list to keep track of all possible ast elements
This is will be useful for being able to marshal and unmarshal the ast,
since we can't easily know what implementation of the Element interface
is supposed to be used.
2025-05-16 15:12:02 -03:00
b9cb8948fc feat(ast): default (partial) implementation of Element
BaseElement to be used by other Elements as a default implementation of
common ast functions
2025-05-16 15:10:22 -03:00
5dc04d29d9 feat(ast): create Element interface 2025-05-16 15:08:06 -03:00
70b6491565 fix(templates): extra div closing tag 2025-03-28 17:19:55 -03:00
b329d8cfba feat(templates): delete interactions via the editor 2025-03-28 17:12:39 -03:00
2862824b7b feat(router): endpoint to delete interactions 2025-03-28 16:44:11 -03:00
0bfc828caf feat(router,templates): add interactions to page via editor 2025-03-28 16:43:45 -03:00
3524eb2944 feat(service): UpdatePage method 2025-03-28 16:41:47 -03:00
cdcc410089 feat(service): add interaction to page struct 2025-03-28 16:41:31 -03:00
757ed62edd refactor(router): rename imgID to pageID 2025-03-28 16:40:51 -03:00
5c873a2707 refactor(service,router): return ProjectPage struct instead of just image reader 2025-03-28 16:39:32 -03:00
8af80c702f refactor(service): make project pages be a slice instead of map 2025-03-28 16:37:23 -03:00
7e78726bcb feat: delete pages of projects 2025-03-25 14:58:46 -03:00
bd5132354f fix(templates): z index of header being lower than content 2025-03-25 14:53:52 -03:00
788fdfd9e3 feat(templates): support for templates images in project page 2025-03-25 14:53:21 -03:00
e4d53084a6 fix: delete button in project card is not clickable 2025-03-25 14:52:52 -03:00
01eb5d90e0 fix: delete pages and images on project deletion 2025-03-25 14:52:28 -03:00
268e0a9d8b feat: page manipulation in projects 2025-03-25 14:33:42 -03:00
f13313da30 refactor(router): add trailing slash to all redirects 2025-03-25 14:31:35 -03:00
07460aaaca refactor(router): add trailing slash to all endpoints 2025-03-25 14:31:21 -03:00
845d4b40c3 feat(router): delete project route and method 2025-03-19 11:28:46 -03:00
b93ff0512f feat(service): delete project method 2025-03-19 11:28:05 -03:00
7c1246adb4 feat(db): delete project method 2025-03-19 11:27:48 -03:00
329b2ca953 feat(templates): list projects on dashboard 2025-03-17 16:21:23 -03:00
8273ff6a1d feat(templates): hot reloading templates
this is supposed to be mostly temporaly until a better templates
interface is made
2025-03-17 15:43:51 -03:00
82f2c7e67a fix(templates,layouts): update main stylesheet location 2025-03-17 11:20:34 -03:00
69a291d19e feat(comicverse): support for local assets files 2025-03-17 11:20:00 -03:00
fa66837cdd refactor(router): rename static files to assets 2025-03-17 11:19:34 -03:00
47c3de3c8f refactor(comicverse): rename static files to assets 2025-03-17 11:19:20 -03:00
e40896c53f chore: update build script for assets 2025-03-17 10:55:35 -03:00
bfe7a01aa5 chore: ignore output tailwind file 2025-03-17 10:50:06 -03:00
7c28a53965 chore: update dev/assets script 2025-03-17 10:49:51 -03:00
dac00296b7 chore: remove unused static directory 2025-03-17 10:48:53 -03:00
9579d83661 refator(assets): rename static package to assets 2025-03-17 10:47:37 -03:00
4ae94cfe7d chore(go): update to golang 1.24.1 2025-03-17 10:05:09 -03:00
de99160688 chore(deps): update to tailwind 4 2025-03-17 10:01:36 -03:00
c6d99690ed feat(router,service): list projects endpoint 2025-03-17 09:13:55 -03:00
99a76dcad3 refactor(router): remove debugging log 2025-03-12 14:50:40 -03:00
a2ca597578 refactor(router): move projects endpoint to dedicated file 2025-03-12 14:48:43 -03:00
9e5a15963e fix(router): remove unused error ErrProjectInvaldiUUID exception 2025-03-12 14:43:55 -03:00
2d9b3e29d6 fix(service): return error on failed ID generation 2025-03-12 10:51:35 -03:00
f45aff6d6f chore: add litecli to tools in devshell 2025-03-12 10:42:29 -03:00
e121bbde87 fix: debugger profiles not pointing to main package 2025-03-12 10:25:09 -03:00
8708a29a21 chore: update submodule 2025-03-12 10:24:04 -03:00
94e6396a6c chore: ignore database file 2025-03-12 10:23:54 -03:00
ab61af503e feat(router): endpoints for getting and creating projects 2025-03-12 10:22:55 -03:00
8fbb9e1671 feat(service): projects creation and getters implementation 2025-03-12 10:21:44 -03:00
ae10dfa7ca fix(router): conflict between /projects/ and /projects/{id} routes 2025-03-12 10:19:39 -03:00
ea8ca4284b feat(cmd): panic on assertions errors in developer mode 2025-03-12 10:16:58 -03:00
71cd17bb97 feat(database): make all database operation be methods instead of methods+structs
This is inspired by the output code generated by sqlc
2025-03-12 10:15:38 -03:00
9fbcbb96c0 fix(database): missing error join in database constructor 2025-03-12 10:14:38 -03:00
4aeeb8479b refactor(router): reorganize code in router constructor 2025-03-12 10:13:09 -03:00
6eb4825d1c feat(service): pass bucket name for service
This is probably temporaly, it would be better in the future to have a
abstraction on top of the S3 bucket, similar to the database
abstraction.
2025-03-12 10:12:33 -03:00
c8285833d4 feat: add groups to loggers 2025-03-12 10:06:06 -03:00
4ee46e2dc8 refactor(service): use service as struct instead of interface 2025-03-12 10:04:43 -03:00
3d346ca5fe fix(cmd): incorrect local database url 2025-03-12 10:01:59 -03:00
fca5ad29b9 feat(service,database): new Database abstraction to initiate and manipulate database 2025-03-11 14:19:21 -03:00
1c608b30be feat(service): pass context to service 2025-03-11 09:57:10 -03:00
789512f6e1 refactor(comicverse): rename context field to ctx 2025-03-11 09:56:26 -03:00
feaa21b827 chore: remove accidently commited empty .env 2025-03-11 09:47:39 -03:00
32329e1e17 feat: pass s3 client to service 2025-03-11 09:46:03 -03:00
2187848712 feat: pass sql database client to service 2025-03-11 09:45:41 -03:00
eb53285f03 feat(service): new service abstraction to directly interact with DBs and operations
This should make the router be just about HTML rendering, paramaters
validation and routing.
2025-03-11 09:40:48 -03:00
98c389cb0c fix: missing templates import 2025-03-07 20:52:02 -03:00
800412d315 feat: injection of templates into router 2025-03-07 20:51:43 -03:00
6e2664756b feat: static files injection into router 2025-03-07 20:48:11 -03:00
bc658d7dc8 refactor: new router module to comicverse app 2025-03-07 20:46:17 -03:00
5f88be7244 feat: comicverse app struc 2025-03-07 20:40:31 -03:00
4be737b292 fix: move main() function to cmd/cmd.go 2025-03-07 20:35:28 -03:00
b0c6d70406 fix: tailwind cli not properly watch files 2025-03-07 20:35:05 -03:00
fc757d36f0 feat: use cmd as main package 2025-03-07 20:34:31 -03:00
0ae642f17b feat: exit execution on error 2025-03-07 20:34:08 -03:00
145da05708 feat: rename host flag to hostname 2025-03-07 20:33:39 -03:00
8cb5ca3917 feat: remove proxy from live server 2025-03-07 20:32:57 -03:00
ee93e78b28 chore: remove unused index.html 2025-03-06 16:57:28 -03:00
f9e9d95c80 feat: dashboard endpoint 2025-03-06 16:56:31 -03:00
742287e522 feat: dashboard template 2025-03-06 16:56:21 -03:00
6ddba55413 chore: remove /panic endpoint 2025-03-06 16:56:01 -03:00
7de9126ea1 chore: remove /test endpoint 2025-03-06 16:55:41 -03:00
dc33adb733 feat: args func to pass multiple arguments to templates 2025-03-06 16:55:16 -03:00
6146819503 chore: remove test layout 2025-03-06 16:54:44 -03:00
ac4c681b7c feat: page layout 2025-03-06 16:54:36 -03:00
c25e8b0f1d feat: base layout 2025-03-06 16:54:14 -03:00
51 changed files with 1993 additions and 1202 deletions

5
.EXAMPLE.env Normal file
View File

@@ -0,0 +1,5 @@
AWS_ACCESS_KEY_ID=**************************
AWS_SECRET_ACCESS_KEY=****************************************************************
AWS_DEFAULT_REGION=******
AWS_ENDPOINT_URL=http://localhost:3900
DATABASE_URL=file:./libsql.db

0
.env
View File

5
.gitignore vendored
View File

@@ -1,3 +1,6 @@
.dist
wind.css
out.css
.tmp
.env
*.db
tmp

6
.vscode/launch.json vendored
View File

@@ -6,15 +6,15 @@
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/main.go"
"program": "${workspaceFolder}/cmd/cmd.go"
},
{
"name": "Launch APP (Dev)",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/main.go",
"args": [ "-dev" ]
"program": "${workspaceFolder}/cmd/cmd.go",
"args": ["-dev", "-port", "8080", "-hostname", "0.0.0.0"]
}
]
}

22
assets/assets.go Normal file
View File

@@ -0,0 +1,22 @@
package assets
import (
"embed"
"io/fs"
)
//go:embed stylesheets/out.css
var files embed.FS
func Files(local ...bool) fs.FS {
var l bool
if len(local) > 0 {
l = local[0]
}
if !l {
return files
}
return files
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -1,39 +1,72 @@
package cmd
package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"forge.capytal.company/capytalcode/project-comicverse/router"
comicverse "forge.capytal.company/capytalcode/project-comicverse"
"forge.capytal.company/capytalcode/project-comicverse/templates"
"forge.capytal.company/loreddev/x/tinyssert"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
_ "github.com/tursodatabase/go-libsql"
)
var (
host = flag.String("host", "localhost", "Host to listen to")
hostname = flag.String("hostname", "localhost", "Host to listen to")
port = flag.Uint("port", 8080, "Port to be used for the server.")
templatesDir = flag.String("templates", "", "Templates directory to be used instead of built-in ones.")
verbose = flag.Bool("verbose", false, "Print debug information on logs")
dev = flag.Bool("dev", false, "Run the server in debug mode.")
)
func init() {
flag.Parse()
var (
databaseURL = getEnv("DATABASE_URL", "file:./libsql.db")
awsAccessKeyID = os.Getenv("AWS_ACCESS_KEY_ID")
awsSecretAccessKey = os.Getenv("AWS_SECRET_ACCESS_KEY")
awsDefaultRegion = os.Getenv("AWS_DEFAULT_REGION")
awsEndpointURL = os.Getenv("AWS_ENDPOINT_URL")
s3Bucket = os.Getenv("S3_BUCKET")
)
func getEnv(key string, d string) string {
v := os.Getenv(key)
if v == "" {
return d
}
return v
}
func Execute() {
ctx := context.Background()
func init() {
flag.Parse()
assertions := tinyssert.NewDisabledAssertions()
if *dev {
assertions = tinyssert.NewAssertions()
switch {
case databaseURL == "":
log.Fatal("DATABASE_URL should not be a empty value")
case awsAccessKeyID == "":
log.Fatal("AWS_ACCESS_KEY_ID should not be a empty value")
case awsDefaultRegion == "":
log.Fatal("AWS_DEFAULT_REGION should not be a empty value")
case awsEndpointURL == "":
log.Fatal("AWS_ENDPOINT_URL should not be a empty value")
case s3Bucket == "":
log.Fatal("S3_BUCKET should not be a empty value")
}
}
func main() {
ctx := context.Background()
level := slog.LevelError
if *dev {
@@ -43,10 +76,62 @@ func Execute() {
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
app := router.New(assertions, log, *dev)
assertions := tinyssert.NewDisabled()
if *dev {
assertions = tinyssert.New(
tinyssert.WithPanic(),
tinyssert.WithLogger(log),
)
}
db, err := sql.Open("libsql", databaseURL)
if err != nil {
log.Error("Failed open connection to database", slog.String("error", err.Error()))
os.Exit(1)
}
credentials := aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {
return aws.Credentials{
AccessKeyID: awsAccessKeyID,
SecretAccessKey: awsSecretAccessKey,
CanExpire: false,
}, nil
})
storage := s3.New(s3.Options{
AppID: "comicverse-pre-alpha",
BaseEndpoint: &awsEndpointURL,
Region: awsDefaultRegion,
Credentials: &credentials,
})
opts := []comicverse.Option{
comicverse.WithContext(ctx),
comicverse.WithAssertions(assertions),
comicverse.WithLogger(log),
}
if *dev {
d := os.DirFS("./assets")
opts = append(opts, comicverse.WithAssets(d))
t := templates.NewHotTemplates(os.DirFS("./templates"))
opts = append(opts, comicverse.WithTemplates(t))
opts = append(opts, comicverse.WithDevelopmentMode())
}
app, err := comicverse.New(comicverse.Config{
DB: db,
S3: storage,
Bucket: s3Bucket,
}, opts...)
if err != nil {
log.Error("Failed to initiate comicverse app", slog.String("error", err.Error()))
os.Exit(1)
}
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", *host, *port),
Addr: fmt.Sprintf("%s:%d", *hostname, *port),
Handler: app,
}
@@ -55,13 +140,14 @@ func Execute() {
go func() {
log.Info("Starting application",
slog.String("host", *host),
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", slog.String("error", err.Error()))
log.Error("Failed to start application server", slog.String("error", err.Error()))
os.Exit(1)
}
}()
@@ -69,7 +155,8 @@ func Execute() {
log.Info("Stopping application gracefully")
if err := srv.Shutdown(ctx); err != nil {
log.Error("Failed to stop application gracefully", slog.String("error", err.Error()))
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
os.Exit(1)
}
log.Info("FINAL")

160
comicverse.go Normal file
View File

@@ -0,0 +1,160 @@
package comicverse
import (
"context"
"database/sql"
"errors"
"io"
"io/fs"
"log/slog"
"net/http"
"forge.capytal.company/capytalcode/project-comicverse/assets"
"forge.capytal.company/capytalcode/project-comicverse/internals/joinedfs"
"forge.capytal.company/capytalcode/project-comicverse/repository"
"forge.capytal.company/capytalcode/project-comicverse/router"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/capytalcode/project-comicverse/templates"
"forge.capytal.company/loreddev/x/tinyssert"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func New(cfg Config, opts ...Option) (http.Handler, error) {
app := &app{
db: cfg.DB,
s3: cfg.S3,
bucket: cfg.Bucket,
assets: assets.Files(),
templates: templates.Templates(),
developmentMode: false,
ctx: context.Background(),
assert: tinyssert.New(),
logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})),
}
for _, opt := range opts {
opt(app)
}
if app.db == nil {
return nil, errors.New("database interface must not be nil")
}
if app.s3 == nil {
return nil, errors.New("s3 client must not be nil")
}
if app.bucket == "" {
return nil, errors.New("bucket must not be a empty string")
}
if app.assets == nil {
return nil, errors.New("static files must not be a nil interface")
}
if app.templates == nil {
return nil, errors.New("templates must not be a nil interface")
}
if app.ctx == nil {
return nil, errors.New("context must not be a nil interface")
}
if app.logger == nil {
return nil, errors.New("logger must not be a nil interface")
}
if app.assert == nil {
return nil, errors.New("assertions must not be a nil interface")
}
return app, app.setup()
}
type Config struct {
DB *sql.DB
S3 *s3.Client
Bucket string
}
type Option func(*app)
func WithContext(ctx context.Context) Option {
return func(app *app) { app.ctx = ctx }
}
func WithAssets(f fs.FS) Option {
return func(app *app) { app.assets = joinedfs.Join(f, app.assets) }
}
func WithTemplates(t templates.ITemplate) Option {
return func(app *app) { app.templates = t }
}
func WithAssertions(a tinyssert.Assertions) Option {
return func(app *app) { app.assert = a }
}
func WithLogger(l *slog.Logger) Option {
return func(app *app) { app.logger = l }
}
func WithDevelopmentMode() Option {
return func(app *app) { app.developmentMode = true }
}
type app struct {
db *sql.DB
s3 *s3.Client
bucket string
ctx context.Context
assets fs.FS
templates templates.ITemplate
developmentMode bool
handler http.Handler
assert tinyssert.Assertions
logger *slog.Logger
}
func (app *app) setup() error {
app.assert.NotNil(app.db)
app.assert.NotNil(app.s3)
app.assert.NotZero(app.bucket)
app.assert.NotNil(app.ctx)
app.assert.NotNil(app.assets)
app.assert.NotNil(app.logger)
userRepo, err := repository.NewUserRepository(app.db, app.ctx, app.logger, app.assert)
if err != nil {
return err
}
userService, err := service.NewUserService(userRepo, app.assert)
if err != nil {
return err
}
app.handler, err = router.New(router.Config{
UserService: userService,
Templates: app.templates,
DisableCache: app.developmentMode,
Assets: app.assets,
Assertions: app.assert,
Logger: app.logger.WithGroup("router"),
})
if err != nil {
return errors.Join(errors.New("unable to initiate router"), err)
}
return err
}
func (app *app) ServeHTTP(w http.ResponseWriter, r *http.Request) {
app.assert.NotNil(app.handler)
app.handler.ServeHTTP(w, r)
}

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1726243404,
"narHash": "sha256-sjiGsMh+1cWXb53Tecsm4skyFNag33GPbVgCdfj3n9I=",
"lastModified": 1742069588,
"narHash": "sha256-C7jVfohcGzdZRF6DO+ybyG/sqpo1h6bZi9T56sxLy+k=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "345c263f2f53a3710abe117f28a5cb86d0ba4059",
"rev": "c80f6a7e10b39afcc1894e02ef785b1ad0b0d7e5",
"type": "github"
},
"original": {

View File

@@ -18,8 +18,14 @@
in {
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
CGO_ENABLED = "0";
hardeningDisable = ["all"];
CGO_ENABLED = "1";
hardeningDisable = ["fortify"];
shellHook = ''
set -a
source .env
set +a
'';
buildInputs = with pkgs; [
# Go tools
@@ -30,11 +36,15 @@
delve
# TailwindCSS
tailwindcss
tailwindcss_4
# Sqlite tools
sqlite
lazysql
litecli
# S3
awscli
];
};
});

27
go.mod
View File

@@ -1,7 +1,28 @@
module forge.capytal.company/capytalcode/project-comicverse
go 1.23.3
go 1.24.1
toolchain go1.23.6
require (
forge.capytal.company/loreddev/x v0.0.0-20250305165122-0ccb26ab783b
github.com/aws/aws-sdk-go-v2 v1.36.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92
golang.org/x/crypto v0.38.0
)
require forge.capytal.company/loreddev/x v0.0.0-20250227192157-90a5169f1bef
require (
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
)

48
go.sum
View File

@@ -1,2 +1,46 @@
forge.capytal.company/loreddev/x v0.0.0-20250227192157-90a5169f1bef h1:IJ9z7otITB5hhjZ+bmU0yOVsa8K1RWYIZ+cQj9XF6NY=
forge.capytal.company/loreddev/x v0.0.0-20250227192157-90a5169f1bef/go.mod h1:MnU08vmXvYIQlQutVcC6o6Xq1KHZuXGXO78bbHseCFo=
forge.capytal.company/loreddev/x v0.0.0-20250305165122-0ccb26ab783b h1:QxTrkGp1cBiPs5vd1Lkh+I/3kNc82CQ5VkF3Cp+8R3E=
forge.capytal.company/loreddev/x v0.0.0-20250305165122-0ccb26ab783b/go.mod h1:Fc5nkrgOwJYdiwZK9SElFAB5xd7C/fh/mD+tBERfUPM=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1 h1:1M0gSbyP6q06gl3384wpoKPaH9G16NPqZFieEhLboSU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92 h1:IYI1S1xt4WdQHjgVYzMa+Owot82BqlZfQV05BLnTcTA=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

View File

@@ -1,4 +1,4 @@
go 1.23.6
go 1.24.1
use (
./.

View File

@@ -0,0 +1,25 @@
package joinedfs
import "io/fs"
func Join(fsys ...fs.FS) fs.FS {
return &joinedFS{fsys}
}
type joinedFS struct {
fsys []fs.FS
}
var _ fs.FS = (*joinedFS)(nil)
func (j *joinedFS) Open(name string) (fs.File, error) {
var err error
var f fs.File
for _, fsys := range j.fsys {
f, err = fsys.Open(name)
if err == nil {
return f, nil
}
}
return f, err
}

View File

@@ -0,0 +1,75 @@
// This file has code copied from the "randstr" Go module, which can be found at
// https://github.com/thanhpk/randsr. The original code is licensed under the MIT
// license, which a copy can be found at https://github.com/thanhpk/randstr/blob/master/LICENSE
// and is provided below:
//
// # The MIT License
//
// Copyright (c) 2010-2018 Google, Inc. http://angularjs.org
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// Package randstr provides basic functions for generating random bytes, string
package randstr
import (
"bytes"
"crypto/rand"
"encoding/binary"
)
// HexChars holds a string containing all characters used in a hexadecimal value.
const HexChars = "0123456789abcdef"
// NewHex generates a new Hexadecimal string with length of n
//
// Example: 67aab2d956bd7cc621af22cfb169cba8
func NewHex(n int) (string, error) { return New(n, HexChars) }
// New generates a random string using only letters provided in the letters parameter.
//
// If the letters parameter is omitted, this function will use HexChars instead.
func New(n int, chars ...string) (string, error) {
runes := []rune(HexChars)
if len(chars) > 0 {
runes = []rune(chars[0])
}
var b bytes.Buffer
b.Grow(n)
l := uint32(len(runes))
for range n {
by, err := Bytes(4)
if err != nil {
return "", err
}
b.WriteRune(runes[binary.BigEndian.Uint32(by)%l])
}
return b.String(), nil
}
// Bytes generates n random bytes
func Bytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return []byte{}, err
}
return b, nil
}

195
ipub/ast/ast.go Normal file
View File

@@ -0,0 +1,195 @@
package ast
import (
"fmt"
)
type Node interface {
Kind() NodeKind
NextSibling() Node
SetNextSibling(Node)
PreviousSibling() Node
SetPreviousSibling(Node)
Parent() Node
SetParent(Node)
HasChildren() bool
ChildCount() uint
FirstChild() Node
LastChild() Node
AppendChild(self, v Node)
RemoveChild(self, v Node)
RemoveChildren(self Node)
ReplaceChild(self, v1, insertee Node)
InsertBefore(self, v1, insertee Node)
InsertAfter(self, v1, insertee Node)
}
type BaseNode struct {
next Node
prev Node
parent Node
fisrtChild Node
lastChild Node
childCount uint
}
func (e *BaseNode) NextSibling() Node {
return e.next
}
func (e *BaseNode) SetNextSibling(v Node) {
e.next = v
}
func (e *BaseNode) PreviousSibling() Node {
return e.prev
}
func (e *BaseNode) SetPreviousSibling(v Node) {
e.prev = v
}
func (e *BaseNode) Parent() Node {
return e.parent
}
func (e *BaseNode) SetParent(v Node) {
e.parent = v
}
func (e *BaseNode) HasChildren() bool {
return e.fisrtChild != nil
}
func (e *BaseNode) ChildCount() uint {
return e.childCount
}
func (e *BaseNode) FirstChild() Node {
return e.fisrtChild
}
func (e *BaseNode) LastChild() Node {
return e.lastChild
}
func (e *BaseNode) AppendChild(self, v Node) {
ensureIsolated(v)
if e.fisrtChild == nil {
e.fisrtChild = v
v.SetNextSibling(nil)
v.SetPreviousSibling(nil)
} else {
l := e.lastChild
l.SetNextSibling(v)
v.SetPreviousSibling(l)
}
v.SetParent(self)
e.lastChild = v
e.childCount++
}
func (e *BaseNode) RemoveChild(self, v Node) {
if v.Parent() != self {
return
}
if e.childCount <= 0 {
e.childCount--
}
prev := v.PreviousSibling()
next := v.NextSibling()
if prev != nil {
prev.SetNextSibling(next)
} else {
e.fisrtChild = next
}
if next != nil {
next.SetNextSibling(prev)
} else {
e.lastChild = prev
}
v.SetParent(nil)
v.SetNextSibling(nil)
v.SetPreviousSibling(nil)
}
func (e *BaseNode) RemoveChildren(_ Node) {
for c := e.fisrtChild; c != nil; {
c.SetParent(nil)
c.SetPreviousSibling(nil)
next := c.NextSibling()
c.SetNextSibling(nil)
c = next
}
e.fisrtChild = nil
e.lastChild = nil
e.childCount = 0
}
func (e *BaseNode) ReplaceChild(self, v1, insertee Node) {
e.InsertBefore(self, v1, insertee)
e.RemoveChild(self, v1)
}
func (e *BaseNode) InsertAfter(self, v1, insertee Node) {
e.InsertBefore(self, v1.NextSibling(), insertee)
}
func (e *BaseNode) InsertBefore(self, v1, insertee Node) {
e.childCount++
if v1 == nil {
e.AppendChild(self, insertee)
return
}
ensureIsolated(insertee)
if v1.Parent() == self {
c := v1
prev := c.PreviousSibling()
if prev != nil {
prev.SetNextSibling(insertee)
insertee.SetPreviousSibling(prev)
} else {
e.fisrtChild = insertee
insertee.SetPreviousSibling(nil)
}
insertee.SetNextSibling(c)
c.SetPreviousSibling(insertee)
insertee.SetParent(self)
}
}
func ensureIsolated(e Node) {
if p := e.Parent(); p != nil {
p.RemoveChild(p, e)
}
}
type NodeKind string
func NewNodeKind(kind string, e Node) NodeKind {
k := NodeKind(kind)
if _, ok := elementKindList[k]; ok {
panic(fmt.Sprintf("Node kind %q is already registered", k))
}
elementKindList[k] = e
return k
}
var elementKindList = make(map[NodeKind]Node)

77
ipub/ast/ast_test.go Normal file
View File

@@ -0,0 +1,77 @@
package ast_test
import (
_ "embed"
"encoding/xml"
"io"
"testing"
"forge.capytal.company/capytalcode/project-comicverse/ipub/ast"
"forge.capytal.company/loreddev/x/tinyssert"
)
//go:embed test.xml
var test []byte
func TestMarshal(t *testing.T) {
b := &ast.Body{}
c := &ast.Content{}
i := &ast.Image{}
i.SetSource("https://hello.com/world.png")
c.AppendChild(c, i)
b.AppendChild(b, c)
s := ast.Section{
Body: b,
}
by, err := xml.Marshal(s)
if err != nil && err != io.EOF {
t.Error(err.Error())
t.FailNow()
}
// t.Logf("%#v", s.Body)
//
// t.Logf("%#v", f)
t.Logf("%#v", string(by))
}
func TestUnmarshal(t *testing.T) {
assert := tinyssert.New(tinyssert.WithTest(t), tinyssert.WithPanic())
s := []byte(`
<html>
<body data-ipub-element="body">
<section data-ipub-element="content">
<img data-ipub-element="image" src="https://hello.com/world.png"/>
</section>
</body>
</html>
`)
var data ast.Section
err := xml.Unmarshal(s, &data)
if err != nil && err != io.EOF {
t.Error(err.Error())
t.FailNow()
}
body := data.Body
assert.Equal(ast.KindBody, body.Kind())
t.Logf("%#v", body)
content := body.FirstChild()
assert.Equal(ast.KindContent, content.Kind())
t.Logf("%#v", content)
img := content.FirstChild().(*ast.Image)
assert.Equal(ast.KindImage, img.Kind())
assert.Equal("https://hello.com/world.png", img.Source())
t.Logf("%#v", img)
}

31
ipub/ast/content.go Normal file
View File

@@ -0,0 +1,31 @@
package ast
type Content struct {
BaseNode
}
var KindContent = NewNodeKind("content", &Content{})
func (e Content) Kind() NodeKind {
return KindContent
}
type Image struct {
src string
BaseNode
}
var KindImage = NewNodeKind("image", &Image{})
func (e *Image) Kind() NodeKind {
return KindImage
}
func (e Image) Source() string {
return e.src
}
func (e *Image) SetSource(src string) {
e.src = src
}

11
ipub/ast/package.go Normal file
View File

@@ -0,0 +1,11 @@
package ast
type Package struct {
BaseNode
}
var KindPackage = NewNodeKind("package", &Package{})
func (e Package) Kind() NodeKind {
return KindPackage
}

20
ipub/ast/section.go Normal file
View File

@@ -0,0 +1,20 @@
package ast
import (
"encoding/xml"
)
type Section struct {
XMLName xml.Name `xml:"html"`
Body *Body `xml:"body"`
}
type Body struct {
BaseNode
}
var KindBody = NewNodeKind("body", &Body{})
func (e Body) Kind() NodeKind {
return KindBody
}

29
ipub/element/attr/attr.go Normal file
View File

@@ -0,0 +1,29 @@
package attr
import (
"encoding/xml"
"fmt"
)
type Attribute interface {
xml.MarshalerAttr
xml.UnmarshalerAttr
fmt.Stringer
}
type BaseAttribute string
func (a BaseAttribute) MarshalXMLAttr(n xml.Name) (xml.Attr, error) {
return xml.Attr{Name: n, Value: a.String()}, nil
}
func (a *BaseAttribute) UnmarshalXMLAttr(attr xml.Attr) error {
*a = BaseAttribute(attr.Value)
return nil
}
func (a BaseAttribute) String() string {
return string(a)
}

View File

@@ -0,0 +1,36 @@
package attr
import (
"encoding/xml"
"fmt"
)
type ErrInvalidName struct {
Actual xml.Name
Expected xml.Name
}
var _ error = ErrInvalidName{}
func (err ErrInvalidName) Error() string {
return fmt.Sprintf("attribute %q has invalid name, expected %q", FmtXMLName(err.Actual), FmtXMLName(err.Expected))
}
type ErrInvalidValue struct {
Attr xml.Attr
Message string
}
var _ error = ErrInvalidValue{}
func (err ErrInvalidValue) Error() string {
return fmt.Sprintf("attribute %q's value %q is invalid: %s", FmtXMLName(err.Attr.Name), err.Attr.Value, err.Message)
}
func FmtXMLName(n xml.Name) string {
s := n.Local
if n.Space != "" {
s = fmt.Sprintf("%s:%s", n.Space, n.Local)
}
return s
}

View File

@@ -0,0 +1,3 @@
package attr
type DataElement = BaseAttribute

112
ipub/element/element.go Normal file
View File

@@ -0,0 +1,112 @@
package element
import (
"encoding/xml"
"errors"
"fmt"
"io"
"reflect"
"slices"
"strings"
"forge.capytal.company/capytalcode/project-comicverse/ipub/element/attr"
)
type Element interface {
Kind() ElementKind
}
type ElementChildren []Element
func (ec *ElementChildren) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
elErr := fmt.Errorf("unable to unsmarshal element %q", attr.FmtXMLName(start.Name))
i := slices.IndexFunc(start.Attr, func(a xml.Attr) bool {
return a.Name == elementKindAttrName
})
if i == -1 {
return errors.Join(elErr, fmt.Errorf("element kind not specified"))
}
var k ElementKind
if err := k.UnmarshalXMLAttr(start.Attr[i]); err != nil {
return err
}
ks := elementKindList[k]
// Get a pointer of a new instance of the underlying implementation so we can
// change it without manipulating the value inside the elementKindList.
ep := reflect.New(reflect.TypeOf(ks))
if ep.Elem().Kind() == reflect.Pointer {
// If the implementation is a pointer, we need the underlying value so we can
// manipulate it.
ep = reflect.New(reflect.TypeOf(ks).Elem())
}
if err := d.DecodeElement(ep.Interface(), &start); err != nil && err != io.EOF {
return errors.Join(elErr, err)
}
if ec == nil {
c := ElementChildren{}
ec = &c
}
s := *ec
s = append(s, ep.Interface().(Element))
*ec = s
return nil
}
type ElementKind string
// NewElementKind registers a new Element implementation to a private list which is
// consumed bu [ElementChildren] to properly find what underlying type is a children
// of another element struct.
func NewElementKind(n string, s Element) ElementKind {
k := ElementKind(n)
if _, ok := elementKindList[k]; ok {
panic(fmt.Sprintf("element kind %q already registered", n))
}
elementKindList[k] = s
return k
}
func (k ElementKind) MarshalXMLAttr(n xml.Name) (xml.Attr, error) {
if n != elementKindAttrName {
return xml.Attr{}, attr.ErrInvalidName{Actual: n, Expected: elementKindAttrName}
}
return xml.Attr{Name: elementKindAttrName, Value: k.String()}, nil
}
func (k *ElementKind) UnmarshalXMLAttr(a xml.Attr) error {
ak := ElementKind(a.Value)
if _, ok := elementKindList[ak]; !ok {
v := make([]string, 0, len(elementKindList))
for k := range elementKindList {
v = append(v, k.String())
}
return attr.ErrInvalidValue{
Attr: a,
Message: fmt.Sprintf("must be a registered element (%q)", strings.Join(v, `", "`)),
}
}
*k = ak
return nil
}
func (k ElementKind) String() string {
return string(k)
}
var (
elementKindList = make(map[ElementKind]Element)
elementKindAttrName = xml.Name{Local: "data-ipub-element"}
)

View File

@@ -0,0 +1,47 @@
package element_test
import (
"encoding/xml"
"testing"
"forge.capytal.company/capytalcode/project-comicverse/ipub/element"
)
func Test(t *testing.T) {
d := element.Section{
Body: element.Body{
Test: "helloworld",
Children: []element.Element{
&element.Paragraph{
DataElement: element.ParagraphKind,
Text: "hello world",
Test: "testvalue",
},
&element.Paragraph{
DataElement: element.ParagraphKind,
Text: "hello world 2",
},
},
},
}
b, err := xml.Marshal(d)
if err != nil {
t.Error(err.Error())
t.FailNow()
}
t.Logf("%#v", string(b))
var ud element.Section
err = xml.Unmarshal(b, &ud)
if err != nil {
t.Error(err)
t.FailNow()
}
t.Logf("%#v", ud)
t.Logf("%#v", ud.Body.Children[0])
t.Logf("%#v", ud.Body.Children[1])
}

41
ipub/element/sections.go Normal file
View File

@@ -0,0 +1,41 @@
package element
import "encoding/xml"
type Section struct {
XMLName xml.Name `xml:"html"`
Body Body `xml:"body"`
}
var KindSection = NewElementKind("section", Section{})
func (Section) Kind() ElementKind {
return KindSection
}
type Body struct {
XMLName xml.Name `xml:"body"`
Test string `xml:"test,attr"`
Children ElementChildren `xml:",any"`
}
var KindBody = NewElementKind("body", Body{})
func (Body) Kind() ElementKind {
return KindBody
}
type Paragraph struct {
XMLName xml.Name `xml:"p"`
DataElement ElementKind `xml:"data-ipub-element,attr"`
Test string `xml:"test,attr"`
Text string `xml:",chardata"`
}
var KindParagraph = NewElementKind("paragraph", Paragraph{})
func (Paragraph) Kind() ElementKind {
return KindParagraph
}

View File

@@ -1,7 +0,0 @@
package main
import "forge.capytal.company/capytalcode/project-comicverse/cmd"
func main() {
cmd.Execute()
}

View File

@@ -10,26 +10,40 @@ fmt:
dev/server:
go run github.com/joho/godotenv/cmd/godotenv@v1.5.1 \
go run github.com/air-verse/air@v1.52.2 \
--build.cmd "go build -o .tmp/bin/main ." \
--build.bin ".tmp/bin/main" \
--build.cmd "go build -o tmp/bin/main ./cmd" \
--build.bin "tmp/bin/main" \
--build.exclude_dir "node_modules" \
--build.include_ext "go,html" \
--build.include_ext "go" \
--build.stop_on_error "false" \
--proxy.enabled true \
--proxy.proxy_port $(PORT) \
--proxy.app_port $$(($(PORT) + 1)) \
--misc.clean_on_exit true \
-- -dev -port $$(($(PORT) + 1))
-- -dev -port $(PORT) -hostname 0.0.0.0
dev/assets:
tailwindcss -o ./static/css/wind.css -w
tailwindcss \
-i ./assets/stylesheets/tailwind.css \
-o ./assets/stylesheets/out.css \
--watch
dev:
$(MAKE) -j2 dev/server dev/assets
$(MAKE) -j2 dev/assets dev/server
dev/debug:
$(MAKE) -j2 debug dev/assets
build:
go generate
debug:
dlv debug -l 127.0.0.1:38697 \
--continue \
--accept-multiclient \
--headless \
./cmd -- -dev -port $(PORT) -hostname 0.0.0.0
build/assets:
tailwindcss \
-i ./assets/stylesheets/tailwind.css \
-o ./assets/stylesheets/out.css \
--minify
build: build/assets
go build -o ./.dist/app .
run: build

13
model/user.go Normal file
View File

@@ -0,0 +1,13 @@
package model
import (
"time"
)
type User struct {
Username string `json:"username"` // Must be unique
Password []byte `json:"password"`
DateCreated time.Time `json:"date_created"`
DateUpdated time.Time `json:"date_updated"`
}

16
repository/repository.go Normal file
View File

@@ -0,0 +1,16 @@
package repository
import (
"errors"
"time"
)
var (
ErrDatabaseConn = errors.New("failed to begin transaction/connection with database")
ErrExecuteQuery = errors.New("failed to execute query")
ErrCommitQuery = errors.New("failed to commit transaction")
ErrInvalidData = errors.New("data sent to save is invalid")
ErrNotFound = sql.ErrNoRows
)
var dateFormat = time.RFC3339

173
repository/users.go Normal file
View File

@@ -0,0 +1,173 @@
package repository
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"log/slog"
"time"
"forge.capytal.company/capytalcode/project-comicverse/model"
"forge.capytal.company/loreddev/x/tinyssert"
)
type UserRepository struct {
db *sql.DB
ctx context.Context
log *slog.Logger
assert tinyssert.Assertions
}
func NewUserRepository(
db *sql.DB,
ctx context.Context,
logger *slog.Logger,
assert tinyssert.Assertions,
) (*UserRepository, error) {
assert.NotNil(db)
assert.NotNil(ctx)
assert.NotNil(logger)
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
if err != nil {
return nil, err
}
return &UserRepository{
db: db,
ctx: ctx,
log: logger,
assert: assert,
}, nil
}
func (r *UserRepository) Create(u model.User) (model.User, error) {
r.assert.NotNil(r.db)
r.assert.NotNil(r.log)
r.assert.NotNil(r.ctx)
tx, err := r.db.BeginTx(r.ctx, nil)
if err != nil {
return model.User{}, err
}
q := `
INSERT INTO users (username, password_hash, created_at, updated_at)
VALUES (:username, :password_hash, :created_at, :updated_at)
`
log := r.log.With(slog.String("username", u.Username), slog.String("query", q))
log.DebugContext(r.ctx, "Inserting new user")
t := time.Now()
passwd := base64.URLEncoding.EncodeToString(u.Password)
_, err = tx.ExecContext(r.ctx, q,
sql.Named("username", u.Username),
sql.Named("password_hash", passwd),
sql.Named("created_at", t.Format(dateFormat)),
sql.Named("updated_at", t.Format(dateFormat)))
if err != nil {
log.ErrorContext(r.ctx, "Failed to create user", slog.String("error", err.Error()))
return model.User{}, err
}
if err := tx.Commit(); err != nil {
log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return model.User{}, err
}
return u, nil
}
func (r *UserRepository) GetByUsername(username string) (model.User, error) {
r.assert.NotNil(r.db)
r.assert.NotNil(r.log)
r.assert.NotNil(r.ctx)
tx, err := r.db.BeginTx(r.ctx, nil)
if err != nil {
return model.User{}, err
}
q := `
SELECT username, password_hash, created_at, updated_at FROM users
WHERE username = :username
`
log := r.log.With(slog.String("username", username), slog.String("query", q))
log.DebugContext(r.ctx, "Querying user")
row := tx.QueryRowContext(r.ctx, q, sql.Named("username", username))
var password_hash, dateCreated, dateUpdated string
if err = row.Scan(&username, &password_hash, &dateCreated, &dateUpdated); err != nil {
return model.User{}, err
}
if err := tx.Commit(); err != nil {
log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return model.User{}, err
}
passwd, err := base64.URLEncoding.DecodeString(password_hash)
if err != nil {
return model.User{}, err
}
c, err := time.Parse(dateFormat, dateCreated)
if err != nil {
return model.User{}, errors.Join(ErrInvalidData, err)
}
u, err := time.Parse(dateFormat, dateUpdated)
if err != nil {
return model.User{}, errors.Join(ErrInvalidData, err)
}
return model.User{
Username: username,
Password: passwd,
DateCreated: c,
DateUpdated: u,
}, nil
}
func (r *UserRepository) Delete(u model.User) error {
r.assert.NotNil(r.db)
r.assert.NotNil(r.log)
r.assert.NotNil(r.ctx)
tx, err := r.db.BeginTx(r.ctx, nil)
if err != nil {
return err
}
q := `
DELETE FROM users WHERE username = :username
`
log := r.log.With(slog.String("username", u.Username), slog.String("query", q))
log.DebugContext(r.ctx, "Deleting user")
_, err = tx.ExecContext(r.ctx, q, sql.Named("username", u.Username))
if err != nil {
log.ErrorContext(r.ctx, "Failed to delete user", slog.String("error", err.Error()))
return err
}
if err := tx.Commit(); err != nil {
log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return err
}
return nil
}

View File

@@ -2,9 +2,11 @@ package router
import (
"errors"
"io/fs"
"log/slog"
"net/http"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/capytalcode/project-comicverse/templates"
"forge.capytal.company/loreddev/x/smalltrip"
"forge.capytal.company/loreddev/x/smalltrip/exception"
@@ -12,41 +14,105 @@ import (
"forge.capytal.company/loreddev/x/tinyssert"
)
func New(assertions tinyssert.Assertions, log *slog.Logger, dev bool) http.Handler {
type router struct {
userService *service.UserService
templates templates.ITemplate
assets fs.FS
cache bool
assert tinyssert.Assertions
log *slog.Logger
}
func New(cfg Config) (http.Handler, error) {
if cfg.UserService == nil {
return nil, errors.New("user service is nil")
}
if cfg.Templates == nil {
return nil, errors.New("templates is nil")
}
if cfg.Assets == nil {
return nil, errors.New("static files is nil")
}
if cfg.Assertions == nil {
return nil, errors.New("assertions is nil")
}
if cfg.Logger == nil {
return nil, errors.New("logger is nil")
}
r := &router{
userService: cfg.UserService,
templates: cfg.Templates,
assets: cfg.Assets,
cache: !cfg.DisableCache,
assert: cfg.Assertions,
log: cfg.Logger,
}
return r.setup(), nil
}
type Config struct {
UserService *service.UserService
Templates templates.ITemplate
Assets fs.FS
DisableCache bool
Assertions tinyssert.Assertions
Logger *slog.Logger
}
func (router *router) setup() http.Handler {
router.assert.NotNil(router.log)
router.assert.NotNil(router.assets)
log := router.log
log.Debug("Initializing router")
r := smalltrip.NewRouter(
smalltrip.WithAssertions(assertions),
smalltrip.WithAssertions(router.assert),
smalltrip.WithLogger(log.WithGroup("smalltrip")),
)
r.Use(middleware.Logger(log.WithGroup("requests")))
if dev {
log.Debug("Development mode activated, using development middleware")
r.Use(middleware.Dev)
if router.cache {
r.Use(middleware.Cache())
} else {
r.Use(middleware.PersistentCache())
r.Use(middleware.DisableCache())
}
r.Use(exception.PanicMiddleware())
r.Use(exception.Middleware())
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
err := templates.Templates().ExecuteTemplate(w, "index.html", nil)
userController := newUserController(router.userService, router.templates, router.assert)
r.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
// TODO: Add a way to the user to bypass this check and see the landing page.
// Probably a query parameter to bypass like "?landing=true"
if userController.isLogged(r) {
err := router.templates.ExecuteTemplate(w, "dashboard", nil)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
}
return
}
err := router.templates.ExecuteTemplate(w, "landing", nil)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
}
})
r.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
r.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
exception.InternalServerError(errors.New("TEST ERROR"),
exception.WithData("test-data", "test-value"),
).ServeHTTP(w, r)
})
r.HandleFunc("/panic", func(w http.ResponseWriter, r *http.Request) {
panic("TEST PANIC")
})
r.HandleFunc("/login/{$}", userController.login)
r.HandleFunc("/register/{$}", userController.register)
return r
}
func dashboard(w http.ResponseWriter, r *http.Request) {
}

139
router/users.go Normal file
View File

@@ -0,0 +1,139 @@
package router
import (
"errors"
"net/http"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/capytalcode/project-comicverse/templates"
"forge.capytal.company/loreddev/x/smalltrip/exception"
"forge.capytal.company/loreddev/x/tinyssert"
)
type userController struct {
assert tinyssert.Assertions
templates templates.ITemplate
service *service.UserService
}
func newUserController(
service *service.UserService,
templates templates.ITemplate,
assert tinyssert.Assertions,
) userController {
return userController{
assert: assert,
templates: templates,
service: service,
}
}
func (c userController) login(w http.ResponseWriter, r *http.Request) {
c.assert.NotNil(c.templates)
c.assert.NotNil(c.service)
if r.Method == http.MethodGet {
err := c.templates.ExecuteTemplate(w, "login", nil)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
}
return
}
if r.Method != http.MethodPost {
exception.MethodNotAllowed([]string{http.MethodGet, http.MethodPost}).
ServeHTTP(w, r)
return
}
user, passwd := r.FormValue("username"), r.FormValue("password")
if user == "" {
exception.BadRequest(errors.New(`missing "username" form value`)).ServeHTTP(w, r)
return
}
if passwd == "" {
exception.BadRequest(errors.New(`missing "password" form value`)).ServeHTTP(w, r)
return
}
// TODO: Move token issuing to it's own service, make UserService.Login just return the user
token, _, err := c.service.Login(user, passwd)
if errors.Is(err, service.ErrNotFound) {
exception.NotFound(exception.WithError(errors.New("user not found"))).ServeHTTP(w, r)
return
} else if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
// TODO: harden the cookie policy to the same domain
cookie := &http.Cookie{
Path: "/",
HttpOnly: true,
Name: "token",
Value: token,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (ctrl userController) register(w http.ResponseWriter, r *http.Request) {
ctrl.assert.NotNil(ctrl.templates)
ctrl.assert.NotNil(ctrl.service)
if r.Method == http.MethodGet {
err := ctrl.templates.ExecuteTemplate(w, "register", nil)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
}
return
}
if r.Method != http.MethodPost {
exception.MethodNotAllowed([]string{http.MethodGet, http.MethodPost}).ServeHTTP(w, r)
return
}
user, passwd := r.FormValue("username"), r.FormValue("password")
if user == "" {
exception.BadRequest(errors.New(`missing "username" form value`)).ServeHTTP(w, r)
return
}
if passwd == "" {
exception.BadRequest(errors.New(`missing "password" form value`)).ServeHTTP(w, r)
return
}
_, err := ctrl.service.Register(user, passwd)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
// TODO: Move token issuing to it's own service, make UserService.Login just return the user
token, _, err := ctrl.service.Login(user, passwd)
if err == service.ErrNotFound {
exception.NotFound(exception.WithError(errors.New("user not found"))).ServeHTTP(w, r)
return
} else if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
// TODO: harden the cookie policy to the same domain
cookie := &http.Cookie{
Path: "/",
HttpOnly: true,
Name: "token",
Value: token,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (ctrl userController) isLogged(r *http.Request) bool {
// TODO: Check if token in valid (depends on token service being implemented)
cs := r.CookiesNamed("token")
return len(cs) > 0
}

1
service/service.go Normal file
View File

@@ -0,0 +1 @@
package service

34
service/token.go Normal file
View File

@@ -0,0 +1,34 @@
package service
import (
"time"
"forge.capytal.company/capytalcode/project-comicverse/model"
"forge.capytal.company/loreddev/x/tinyssert"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
)
type TokenService struct {
assert tinyssert.Assertions
}
func NewTokenService(assert tinyssert.Assertions) *TokenService {
return &TokenService{assert: assert}
}
func (s *TokenService) Issue(user model.User) (*jwt.Token, error) {
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
now := time.Now()
t := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.RegisteredClaims{
ID: id.String(),
Subject: user.Username,
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
})
}

98
service/user.go Normal file
View File

@@ -0,0 +1,98 @@
package service
import (
"errors"
"time"
"forge.capytal.company/capytalcode/project-comicverse/model"
"forge.capytal.company/capytalcode/project-comicverse/repository"
"forge.capytal.company/loreddev/x/tinyssert"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type UserService struct {
assert tinyssert.Assertions
repo *repository.UserRepository
}
func NewUserService(repo *repository.UserRepository, assert tinyssert.Assertions) (*UserService, error) {
if err := assert.NotNil(repo); err != nil {
return nil, err
}
return &UserService{repo: repo, assert: assert}, nil
}
func (s *UserService) Register(username, password string) (model.User, error) {
s.assert.NotNil(s.repo)
if _, err := s.repo.GetByUsername(username); err == nil {
return model.User{}, ErrAlreadyExists
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return model.User{}, err
}
u := model.User{
Username: username,
Password: hash,
DateCreated: time.Now(),
DateUpdated: time.Now(),
}
u, err = s.repo.Create(u)
if err != nil {
return model.User{}, errors.Join(errors.New("failed to create user model"), err)
}
return u, nil
}
func (s *UserService) Login(username, password string) (signedToken string, user model.User, err error) {
s.assert.NotNil(s.repo)
user, err = s.repo.GetByUsername(username)
if err != nil {
return "", model.User{}, errors.Join(errors.New("unable to find user"), err)
}
err = bcrypt.CompareHashAndPassword(user.Password, []byte(password))
if err != nil {
return "", model.User{}, errors.Join(errors.New("unable to compare passwords"), err)
}
t := time.Now()
jti, err := uuid.NewV7()
if err != nil {
return "", model.User{}, errors.Join(errors.New("unable to generate token ID"), err)
}
// TODO: Use ECDSA, so users can verify that their token is signed by the project
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
// TODO: Add IDs to users
Issuer: "comicverse",
Subject: username,
IssuedAt: jwt.NewNumericDate(t),
NotBefore: jwt.NewNumericDate(t),
ID: jti.String(),
})
signedToken, err = token.SignedString(jwtKey)
if err != nil {
return "", user, errors.Join(errors.New("unable to sign token"), err)
}
return signedToken, user, nil
}
var jwtKey = []byte("ieurqpieurqpoiweurpewoqueiur") // TODO: move to environment variable
var (
ErrAlreadyExists = errors.New("model already exists")
ErrNotFound = repository.ErrNotFound
ErrPasswordTooLong = bcrypt.ErrPasswordTooLong
ErrIncorrectPassword = bcrypt.ErrMismatchedHashAndPassword
)

View File

View File

@@ -1,24 +0,0 @@
package static
import (
"embed"
"io/fs"
)
//go:generate tailwindcss -o static/css/wind.css
//go:embed css/*.css
var staticFiles embed.FS
func Files(local ...bool) fs.FS {
var l bool
if len(local) > 0 {
l = local[0]
}
if !l {
return staticFiles
}
return staticFiles
}

File diff suppressed because it is too large Load Diff

45
templates/dashboard.html Normal file
View File

@@ -0,0 +1,45 @@
{{define "dashboard"}}
{{template "layout-page-start" (args "Title" "Dashboard")}}
<main class="h-full w-full justify-center px-5 py-10 align-middle">
{{if and (ne . nil) (ne (len .) 0)}}
<section class="flex h-64 flex-col gap-5">
<div class="flex justify-between">
<h2 class="text-2xl">Projects</h2>
<form action="/projects/" method="post">
<button class="rounded-full bg-slate-700 p-1 px-3 text-sm text-slate-100">
New project
</button>
</form>
</div>
<div class="grid h-full grid-flow-col grid-rows-1 justify-start gap-5 overflow-scroll">
{{range .}}
<div class="w-38 grid h-full grid-rows-2 bg-slate-500">
<div class="bg-blue-500 p-2">Image</div>
<div class="p-2">
<a href="/projects/{{.ID}}">
<h3>{{.Title}}</h3>
<p>{{.ID}}</p>
</a>
<form action="/projects/{{.ID}}/" method="post">
<input type="hidden" name="x-method" value="delete">
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
Delete
</button>
</form>
</div>
</div>
{{end}}
</div>
</section>
{{else}}
<div class="fixed flex h-screen w-full items-center justify-center top-0 left-0">
<form action="/projects/" method="post">
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
New project
</button>
</form>
</div>
{{end}}
</main>
{{template "layout-page-end"}}
{{end}}

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/static/css/wind.css">
</head>
<body class="text-red-600">
<h1>Hello, world</h1>
</body>
</html>

20
templates/landing.html Normal file
View File

@@ -0,0 +1,20 @@
{{define "landing"}} {{template "layout-page-start" (args "Title"
"ComicVerse")}}
<main class="h-full w-full justify-center px-5 py-10 align-middle">
<div
class="fixed flex flex-col gap-5 h-screen w-full items-center justify-center top-0 left-0"
>
<h1 class="text-3xl font-bold">Welcome back</h1>
<a
href="/login/"
hx-get="/login/"
hx-swap="outerHTML"
hx-select="#login-form"
>
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
Login
</button>
</a>
</div>
</main>
{{template "layout-page-end"}} {{end}}

View File

@@ -0,0 +1,18 @@
{{define "layout-base-start"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link href="/assets/stylesheets/out.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,16 @@
{{define "layout-page-start"}}
{{template "layout-base-start" (args "Title" .Title)}}
<body class="bg-slate-200 text-slate-950 m-0 min-w-screen min-h-screen relative">
<header class="w-full h-7 bg-slate-700 text-slate-50 px-5 flex justify-between top-0 sticky z-100">
<h1>Comicverse</h1>
<ul>
<a href="/dashboard/">Dashboard</a>
</ul>
</header>
{{end}}
{{define "layout-page-end"}}
</body>
{{template "layout-base-end"}}
{{end}}

32
templates/login.html Normal file
View File

@@ -0,0 +1,32 @@
{{define "login"}} {{template "layout-page-start" (args "Title" "Login")}}
<main>
<div
class="w-full h-screen fixed top-0 left-0 flex justify-center items-center"
>
<form
action="/login/"
method="post"
enctype="multipart/form-data"
class="h-fit bg-slate-500 grid grid-cols-1 grid-rows-3 p-5 gap-3"
id="login-form"
>
<h1>Login</h1>
<input
type="text"
name="username"
required
class="bg-slate-200 p-1"
placeholder="Username"
/>
<input
type="password"
name="password"
required
class="bg-slate-200 p-1"
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
</div>
</main>
{{template "layout-page-end"}} {{end}}

View File

@@ -0,0 +1,17 @@
{{define "partials-status"}}
{{template "layout-page-start" (args "Title" .Title)}}
<main class="justify-center align-middle w-full h-full">
<div class="text-center">
<h1>{{.StatusCode}}</h1>
<p>{{.Message}}</p>
<a href="{{.Redirect}}">
{{if .RedirectMessage}}
{{.RedirectMessage}}
{{else}}
Go back
{{end}}
</a>
</div>
</main>
{{template "layout-page-end"}}
{{end}}

75
templates/project.html Normal file
View File

@@ -0,0 +1,75 @@
{{define "project"}}
{{template "layout-page-start" (args "Title" .Title)}}
<div class="fixed w-full h-full bg-green-500 grid grid-cols-4 grid-rows-1">
<nav class="bg-red-500 h-full">
<h1>{{.Title}}</h1>
<p>{{.ID}}</p>
</nav>
<main class="overflow-y-scroll flex justify-center col-span-3 py-20">
<div class="flex flex-col gap-10 h-fit">
{{range $page := .Pages}}
<section id="{{$page.ID}}" class="w-fit">
<!--
INFO: The interaction form could be another page that is shown
when "Add Interaction" is clicked. Said page could be also a partial
than can replace the current image using htmx, so it is
compatible with JavaScript enabled or not.
-->
<div class="flex flex-row">
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/interactions/" method="post" class="w-100">
<div class="flex">
{{if (gt (len $page.Interactions) 0)}}
<div class="relative flex">
<div class="absolute z-2 w-full h-full top-0 left-0">
{{range $interactionID, $interaction := $page.Interactions}}
<a class="absolute" href="{{$interaction.URL}}"
style="top:{{$interaction.Y}}%;left:{{$interaction.X}}%;">
<span
class="bg-red-200 opacity-10 block w-10 h-10 transform -translate-x-[50%] -translate-y-[50%]"></span>
</a>
{{end}}
</div>
<img src="/projects/{{$.ID}}/pages/{{$page.ID}}/" class="z-1 relative">
</div>
{{else}}
<img src="/projects/{{$.ID}}/pages/{{$page.ID}}/" class="z-1 relative">
{{end}}
<input type="range" min="0" max="100" name="y" style="writing-mode: vertical-lr;">
</div>
<input type="range" min="0" max="100" name="x" class="w-full">
<input type="url" required name="link" class="bg-slate-300" placeholder="url of interaction">
<button class="rounded-full bg-blue-700 p-1 px-3 text-sm text-slate-100">
Add interaction
</button>
</form>
{{if (gt (len $page.Interactions) 0)}}
<div class="flex flex-col gap-2">
{{range $interactionID, $interaction := $page.Interactions}}
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/interactions/{{$interactionID}}/"
method="post">
<input type="hidden" name="x-method" value="delete">
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
&#x1F5D1;&#xFE0F;{{$interaction.URL}}
</button>
</form>
{{end}}
</div>
{{end}}
</div>
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/" method="post">
<input type="hidden" name="x-method" value="delete">
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
Delete
</button>
</form>
</section>
{{end}}
<form action="/projects/{{.ID}}/pages/" method="post" enctype="multipart/form-data">
<input type="file" name="image" required>
<button>Add new page</button>
</form>
</div>
</main>
</div>
{{template "layout-page-end"}}
{{end}}

32
templates/register.html Normal file
View File

@@ -0,0 +1,32 @@
{{define "register"}} {{template "layout-page-start" (args "Title" "Login")}}
<main>
<div
class="w-full h-screen fixed top-0 left-0 flex justify-center items-center"
>
<form
action="/register/"
method="post"
enctype="multipart/form-data"
class="h-fit bg-slate-500 grid grid-cols-1 grid-rows-3 p-5 gap-3"
id="login-form"
>
<h1>Login</h1>
<input
type="text"
name="username"
required
class="bg-slate-200 p-1"
placeholder="Username"
/>
<input
type="password"
name="password"
required
class="bg-slate-200 p-1"
placeholder="Password"
/>
<button type="submit">Register</button>
</form>
</div>
</main>
{{template "layout-page-end"}} {{end}}

View File

@@ -1,18 +1,77 @@
package templates
// INFO: This will probably become a new lib in loreddev/x at some point
import (
"embed"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
)
//go:embed *.html test/*.html
var (
patterns = []string{"*.html", "layouts/*.html", "partials/*.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
},
}
)
//go:embed *.html layouts/*.html partials/*.html
var embedded embed.FS
var temps = template.Must(template.ParseFS(embedded,
"*.html",
"test/*.html",
))
var temps = template.Must(template.New("templates").Funcs(functions).ParseFS(embedded, patterns...))
func Templates() *template.Template {
return temps
return temps // TODO: Support for local templates/hot-reloading without rebuild
}
func NewHotTemplates(fsys fs.FS) *HotTemplate {
return &HotTemplate{
fs: fsys,
}
}
type HotTemplate struct {
fs fs.FS
template *template.Template
}
func (t *HotTemplate) Execute(wr io.Writer, data any) error {
te, err := template.New("hot-templates").Funcs(functions).ParseFS(t.fs, patterns...)
if err != nil {
return err
}
return te.Execute(wr, data)
}
func (t *HotTemplate) ExecuteTemplate(wr io.Writer, name string, data any) error {
te, err := template.New("hot-templates").Funcs(functions).ParseFS(t.fs, patterns...)
if err != nil {
return err
}
return te.ExecuteTemplate(wr, name, data)
}
type ITemplate interface {
Execute(wr io.Writer, data any) error
ExecuteTemplate(wr io.Writer, name string, data any) error
}

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<h1>Hello, world 2</h1>
</body>
</html>

2
x

Submodule x updated: 0ccb26ab78...c62be87c6a