10 Commits

70 changed files with 987 additions and 2190 deletions

View File

@@ -1,5 +1,5 @@
AWS_ACCESS_KEY_ID=**************************
AWS_SECRET_ACCESS_KEY=****************************************************************
AWS_DEFAULT_REGION=******
AWS_ENDPOINT_URL=http://localhost:3900
DATABASE_URL=file:./libsql.db
AWS_ENDPOINT_URL=localhost:3000
S3_DEFAULT_BUCKET=comicverse-test-bucket

27
.gitignore vendored
View File

@@ -1,6 +1,23 @@
.dist
out.css
.tmp
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
*.db
tmp
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
data.db

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "x"]
path = x
url = https://forge.capytal.company/loreddev/x

View File

@@ -1,20 +0,0 @@
run:
timeout: 5m
modules-download-mode: readonly
linters:
disable-all: true
enable:
- errcheck
- goimports
- gofumpt
- revive # golint
- gosimple
- govet
- ineffassign
- staticcheck
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

20
.vscode/launch.json vendored
View File

@@ -1,20 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch APP",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/cmd.go"
},
{
"name": "Launch APP (Dev)",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/cmd.go",
"args": ["-dev", "-port", "8080", "-hostname", "0.0.0.0"]
}
]
}

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

View File

@@ -1,22 +0,0 @@
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

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

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -1,164 +0,0 @@
package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
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 (
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.")
)
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 init() {
flag.Parse()
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 {
level = slog.LevelDebug
} else if *verbose {
level = slog.LevelInfo
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
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", *hostname, *port),
Handler: app,
}
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(ctx); err != nil {
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
os.Exit(1)
}
log.Info("FINAL")
os.Exit(0)
}

View File

@@ -1,160 +0,0 @@
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)
}

33
eslint.config.js Normal file
View File

@@ -0,0 +1,33 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
];

6
flake.lock generated
View File

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

View File

@@ -3,7 +3,10 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {nixpkgs, ...}: let
outputs = {
self,
nixpkgs,
}: let
systems = [
"x86_64-linux"
"aarch64-linux"
@@ -18,33 +21,12 @@
in {
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
CGO_ENABLED = "1";
hardeningDisable = ["fortify"];
shellHook = ''
set -a
source .env
set +a
'';
buildInputs = with pkgs; [
# Go tools
go
golangci-lint
gofumpt
gotools
delve
# TailwindCSS
tailwindcss_4
# Sqlite tools
sqlite
lazysql
litecli
# S3
awscli
awscli2
bun
eslint
nodejs_22
nodePackages_latest.prettier
];
};
});

28
go.mod
View File

@@ -1,28 +0,0 @@
module forge.capytal.company/capytalcode/project-comicverse
go 1.24.1
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 (
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
)

46
go.sum
View File

@@ -1,46 +0,0 @@
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,6 +0,0 @@
go 1.24.1
use (
./.
./x
)

View File

@@ -1,25 +0,0 @@
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

@@ -1,75 +0,0 @@
// 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
}

View File

@@ -1,195 +0,0 @@
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)

View File

@@ -1,77 +0,0 @@
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)
}

View File

@@ -1,31 +0,0 @@
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
}

View File

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

View File

@@ -1,20 +0,0 @@
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
}

View File

@@ -1,29 +0,0 @@
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

@@ -1,36 +0,0 @@
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

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

View File

@@ -1,112 +0,0 @@
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

@@ -1,47 +0,0 @@
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])
}

View File

@@ -1,41 +0,0 @@
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,56 +0,0 @@
PORT?=8080
lint:
golangci-lint run .
fmt:
go fmt .
golangci-lint run --fix .
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 ./cmd" \
--build.bin "tmp/bin/main" \
--build.exclude_dir "node_modules" \
--build.include_ext "go" \
--build.stop_on_error "false" \
--misc.clean_on_exit true \
-- -dev -port $(PORT) -hostname 0.0.0.0
dev/assets:
tailwindcss \
-i ./assets/stylesheets/tailwind.css \
-o ./assets/stylesheets/out.css \
--watch
dev:
$(MAKE) -j2 dev/assets dev/server
dev/debug:
$(MAKE) -j2 debug dev/assets
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
./.dist/app
clean:
# Remove generated directories
if [[ -d ".dist" ]]; then rm -r ./.dist; fi
if [[ -d "tmp" ]]; then rm -r ./tmp; fi
if [[ -d "bin" ]]; then rm -r ./bin; fi

View File

@@ -1,13 +0,0 @@
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"`
}

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "comicverse",
"version": "0.0.1",
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^9.6.0",
"@types/node": "^22.6.0",
"blob-util": "^2.0.2",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"minio": "^8.0.1",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"svelte": "^4.2.7",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.3",
"xml-js": "^1.6.11"
},
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"type": "module"
}

View File

@@ -1,16 +0,0 @@
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

View File

@@ -1,173 +0,0 @@
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

@@ -1,118 +0,0 @@
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"
"forge.capytal.company/loreddev/x/smalltrip/middleware"
"forge.capytal.company/loreddev/x/tinyssert"
)
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(router.assert),
smalltrip.WithLogger(log.WithGroup("smalltrip")),
)
r.Use(middleware.Logger(log.WithGroup("requests")))
if router.cache {
r.Use(middleware.Cache())
} else {
r.Use(middleware.DisableCache())
}
r.Use(exception.PanicMiddleware())
r.Use(exception.Middleware())
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.HandleFunc("/login/{$}", userController.login)
r.HandleFunc("/register/{$}", userController.register)
return r
}

View File

@@ -1,139 +0,0 @@
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
}

View File

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

View File

@@ -1,34 +0,0 @@
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),
})
}

View File

@@ -1,98 +0,0 @@
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
)

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

3
src/lib/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { default as s3 } from './s3';
export { default as db } from './sqlite';
export * from './sqlite'

19
src/lib/s3.ts Normal file
View File

@@ -0,0 +1,19 @@
import {
AWS_ENDPOINT_URL,
AWS_ACCESS_KEY_ID,
AWS_DEFAULT_REGION,
AWS_SECRET_ACCESS_KEY
} from '$env/static/private';
import * as Minio from 'minio';
const client = new Minio.Client({
endPoint: AWS_ENDPOINT_URL.split(':')[0],
port: Number(AWS_ENDPOINT_URL.split(':')[1]),
useSSL: false,
region: AWS_DEFAULT_REGION,
accessKey: AWS_ACCESS_KEY_ID,
secretKey: AWS_SECRET_ACCESS_KEY
});
export default client;

37
src/lib/sqlite.ts Normal file
View File

@@ -0,0 +1,37 @@
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
const db = await open({
filename: 'data.db',
driver: sqlite3.cached.Database
});
await db.exec(`
CREATE TABLE IF NOT EXISTS projects (
ID text NOT NULL,
Name text NOT NULL,
PRIMARY KEY(ID)
)
`);
type Project = {
id: string;
title: string;
pages: Page[];
};
type Page = {
title: string;
src: string;
background: string;
iteraction: Iteraction[];
};
type Iteraction = {
x: number;
y: number;
link: string;
};
export type { Project, Iteraction, Page };
export default db;

21
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,21 @@
<svelte:head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.colors.min.css"
/>
<meta name="color-scheme" content="dark" />
</svelte:head>
<main>
<slot></slot>
</main>
<style>
main {
width: 100vw;
height: 100vh;
}
</style>

View File

@@ -0,0 +1,40 @@
import { fail, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db, s3, type Project } from '$lib';
import { AWS_S3_DEFAULT_BUCKET } from '$env/static/private';
export const load = (async ({}) => {
const res = await db.all<Project[]>('SELECT ID, Name FROM projects');
return { projects: res };
}) as PageServerLoad;
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const name = data.get('project-name');
if (!name) return fail(400, { name, missing: true });
const uuid = crypto.randomUUID().split('-')[0];
const res = await db.run('INSERT OR IGNORE INTO projects (ID, Name) VALUES (:id, :name)', {
':id': uuid,
':name': name
});
const project: Project = {
id: uuid,
title: name.toString(),
pages: []
};
await s3.putObject(AWS_S3_DEFAULT_BUCKET, `${uuid}/project.json`, JSON.stringify(project));
if (res.changes == undefined) {
return fail(500, { reason: 'Failed to insert project into database' });
}
return { success: true };
}
};

46
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { Project } from '$lib';
import type { PageData } from './$types';
export let data: PageData;
if ((data.projects.length + 1) % 3 !== 0) {
data.projects.push({ Name: '', ID: '' });
data.projects.push({ Name: '', ID: '' });
}
</script>
<section>
{#each data.projects as p}
<article>
<h1><a data-sveltekit-reload href={`/projects/${p.ID}`}>{p.Name}</a></h1>
<p class="id">{p.ID}</p>
</article>
{/each}
<article>
<form method="POST">
<fieldset role="group">
<input type="text" name="project-name" placeholder="Project Name" required />
<input type="submit" value="Create" />
</fieldset>
</form>
</article>
</section>
<style>
section {
display: grid;
@media (min-width: 768px) {
grid-template-columns: repeat(3, minmax(0%, 1fr));
}
grid-column-gap: var(--pico-grid-column-gap);
grid-row-gap: var(--pico-grid-row-gap);
padding: 1rem var(--pico-grid-row-gap);
}
.id {
font-size: 0.7rem;
opacity: 0.3;
}
</style>

View File

@@ -0,0 +1,41 @@
import { type RequestHandler } from '@sveltejs/kit';
import stream from 'node:stream/promises';
import { db, s3, type Project } from '$lib';
import { AWS_S3_DEFAULT_BUCKET } from '$env/static/private';
import { extname } from 'node:path';
export const GET = (async ({ params }) => {
const file = await s3.getObject(AWS_S3_DEFAULT_BUCKET, `${params.project}/${params.file}`);
file.on('error', (err: any) => {
console.log(err);
});
let chunks: Buffer[] = [];
let buf;
file.on('data', (chunk) => {
chunks.push(Buffer.from(chunk));
});
file.on('end', () => {
buf = Buffer.concat(chunks);
});
await stream.finished(file)
let res = new Response(buf);
res.headers.set(
'Content-Type',
(() => {
switch (extname(params.file!)) {
case '.png':
return 'image/png';
case '.json':
return 'application/json';
}
return 'text/plain';
})()
);
res.headers.set('Cache-Control', 'max-age=604800')
return res;
}) as RequestHandler;

View File

@@ -0,0 +1,101 @@
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import stream from 'node:stream/promises';
import { db, s3, type Project } from '$lib';
import { AWS_S3_DEFAULT_BUCKET } from '$env/static/private';
import { extname } from 'node:path';
export const prerender = false;
export const ssr = false;
export const load = (async ({ params }) => {
const res = await db.get<{ id: string; name: string }>(
'SELECT ID, Name FROM projects WHERE ID = ?',
params.id
);
if (res === undefined) {
return fail(404, { reason: 'Failed to find project into database' });
}
const project = await s3.getObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`);
project.on('error', (err: any) => {
console.log(err);
});
let p: string = '';
project.on('data', (chunk: any) => {
p += chunk;
});
await stream.finished(project);
let proj = JSON.parse(p) as Project;
return { project: proj };
}) as PageServerLoad;
export const actions = {
delete: async ({ params }) => {
const res = await db.run('DELETE FROM projects WHERE ID = ?', params.id);
await s3.removeObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`);
if (res === undefined) {
return fail(500, { reason: 'Failed to delete project' });
}
redirect(303, '/');
},
'delete-file': async ({ params, request }) => {
const form = await request.formData();
const file = form?.get('file') as string;
const project = await s3.getObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`);
project.on('error', (err: any) => {
console.log(err);
});
let p: string = '';
project.on('data', (chunk: any) => {
p += chunk;
});
await stream.finished(project);
let proj = JSON.parse(p) as Project;
proj.pages = proj.pages.filter((p) => p.src != file);
await s3.removeObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/${file}`);
await s3.putObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`, JSON.stringify(proj));
},
addpage: async ({ request, params }) => {
const form = await request.formData();
const file = form?.get('file') as File;
const title = form?.get('title') as string;
const color = form?.get('color') as string;
const iteractions = form?.get('iteractions') as string;
console.log(file);
const project = await s3.getObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`);
project.on('error', (err: any) => {
console.log(err);
});
let p: string = '';
project.on('data', (chunk: any) => {
p += chunk;
});
await stream.finished(project);
let proj = JSON.parse(p) as Project;
const filename = `${crypto.randomUUID().split('-')[0]}${extname(file?.name)}`;
proj.pages.push({
title: title,
src: filename,
background: color,
iteraction: JSON.parse(iteractions)
});
const buf = Buffer.from(await file.arrayBuffer());
await s3.putObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/project.json`, JSON.stringify(proj));
await s3.putObject(AWS_S3_DEFAULT_BUCKET, `${params.id}/${filename}`, buf);
}
} as Actions;

View File

@@ -0,0 +1,384 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { arrayBufferToBlob } from 'blob-util';
import IImage from './IteractiveImage.svelte';
export let data: PageData;
const pages = data.project.pages;
let modal = false;
let reader: Element;
let scroll: number;
let color = hexToRgb(pages[0]?.background ?? '#181818');
let currentPage = 0;
let colorPerc = 0;
let currentColor = color;
let nextColor = hexToRgb(pages[1]?.background ?? pages[0]?.background ?? '#181818');
let currentChunk = 0;
let nextChunk = 0;
let browser = false;
let maxScroll = 0;
let chunk = 0;
let chunks: number[] = [];
onMount(() => {
browser = true;
maxScroll = Math.max(reader.scrollHeight - reader.clientHeight);
});
function hexToRgb(color: string): number[] {
return [
parseInt(color.substring(1, 3), 16) / 255,
parseInt(color.substring(3, 5), 16) / 255,
parseInt(color.substring(5, 7), 16) / 255
];
}
function rgbToHex(rgb: number[]): string {
return `#${
Math.round(rgb[0] * 255)
.toString(16)
.padStart(2, '0') +
Math.round(rgb[1] * 255)
.toString(16)
.padStart(2, '0') +
Math.round(rgb[2] * 255)
.toString(16)
.padStart(2, '0')
}`;
}
function blendRgbColors(c1: number[], c2: number[], ratio: number): number[] {
return [
c1[0] * (1 - ratio) + c2[0] * ratio,
c1[1] * (1 - ratio) + c2[1] * ratio,
c1[2] * (1 - ratio) + c2[2] * ratio
];
}
let fileInput: Element;
let blobUrl: string | undefined = undefined;
let currentIteraction: { x: number; y: number; link: string };
let iteractionUrl = '';
let iteractions: { x: number; y: number; link: string }[] = [];
let imageElement: Element;
let imageX = 0;
let imageY = 0;
let imageWidth = 0;
let imageHeight = 0;
function readFile(file: Blob) {
let reader = new FileReader();
reader.onloadend = function (e) {
let buf = e.target?.result;
let blob = arrayBufferToBlob(buf as ArrayBuffer, file.type);
blobUrl = window.URL.createObjectURL(blob);
};
reader.readAsArrayBuffer(file);
}
let temp: any;
let images: Map<string, { width: number; height: number }> = new Map();
</script>
{#if browser}
<pre style="position: fixed; bottom: 0; font-size: 0.6rem;">
<code
>{JSON.stringify(
{
page: currentPage,
color: {
background: rgbToHex(color),
current: rgbToHex(currentColor),
next: rgbToHex(nextColor),
percentage: Math.round(colorPerc)
},
scroll: {
current: scroll,
max: maxScroll,
chunks: chunks,
currentChunk: currentChunk,
nextChunk: nextChunk
}
},
null,
2
)}
</code>
</pre>
<details style="position: fixed; bottom: 0; font-size: 0.6rem;">
<pre>
<code>{JSON.stringify(pages)}</code>
</pre>
</details>
{/if}
<dialog open={modal}>
<article>
<header>
<button
aria-label="Close"
rel="prev"
on:click={() => {
modal = false;
}}
></button>
<p>
<strong>Add new page</strong>
</p>
</header>
<form method="POST" action="?/addpage" enctype="multipart/form-data">
<input type="text" required placeholder="Page title" name="title" />
<input type="color" required placeholder="Background color" name="color" />
{#if blobUrl}
<div class="blob-image">
<div class="blob-image-image">
<div
class="iteraction-box"
style={`${[
`margin-left:${Math.round((imageX / 100) * imageWidth)}px;`,
`margin-top:${Math.round((imageY / 100) * imageHeight)}px;`
].join('')} pointer-events: none;`}
></div>
{#each iteractions as i}
<a
class="iteraction-box"
href={i.link}
style={[
`margin-left:${Math.round((i.x / 100) * imageWidth)}px;`,
`margin-top:${Math.round((i.y / 100) * imageHeight)}px;`
].join('')}
></a>
{/each}
<img
style="margin: auto 0;"
src={blobUrl}
bind:this={imageElement}
on:mousemove={(e) => {
let rect = imageElement.getBoundingClientRect();
imageX = Math.round(((e.clientX - rect.left) / rect.width) * 100);
imageY = Math.round(((e.clientY - rect.top) / rect.height) * 100);
imageWidth = rect.width;
imageHeight = rect.height;
}}
on:click={() => {
currentIteraction ||= { x: 0, y: 0, link: '' };
currentIteraction.x = imageX;
currentIteraction.y = imageY;
}}
alt=""
/>
</div>
<fieldset role="group">
<input
type="url"
placeholder="Iteraction url"
bind:value={iteractionUrl}
on:change={() => {
currentIteraction ||= { x: 0, y: 0, link: '' };
currentIteraction.link = iteractionUrl;
}}
on:mouseout={() => {
currentIteraction ||= { x: 0, y: 0, link: '' };
currentIteraction.link = iteractionUrl;
}}
/>
<input
type="button"
value="Add"
on:click|preventDefault={() => {
iteractions.push({
x: currentIteraction.x,
y: currentIteraction.y,
link: currentIteraction.link
});
iteractions = iteractions;
}}
/>
</fieldset>
<div>
<code>{imageX} {imageY}</code>
<code>
Iteraction: {JSON.stringify(currentIteraction)}
</code>
<input
style="display:hidden;"
type="text"
value={JSON.stringify(iteractions.filter(Boolean))}
name="iteractions"
/>
</div>
</div>
{/if}
<input
type="file"
required
name="file"
bind:this={fileInput}
on:change={() => {
// @ts-ignore
readFile(fileInput.files[0]);
}}
/>
<input type="submit" value="Add page" />
</form>
</article>
</dialog>
<section class="project">
<aside>
<a data-sveltekit-reload href="/" style="font-size: 0.5rem;">Return home</a>
<section>
<h1>{data.project.title}</h1>
<p class="id">{data.project.id}</p>
<button
class="add"
on:click={() => {
modal = true;
}}>Add page</button
>
<form method="POST">
<input type="submit" formaction="?/delete" value="Delete" class="pico-background-red-500" />
</form>
</section>
</aside>
{#key maxScroll}
{#if browser}
<article
class="reader"
style={`--bg-color: rgba(${color.map((c) => c * 255).join(',')}, 0.8)`}
bind:this={reader}
on:scroll={() => {
scroll = reader.scrollTop;
if (maxScroll === 0) {
maxScroll = Math.max(reader.scrollHeight - reader.clientHeight);
chunk = Math.round(maxScroll / pages.length);
for (let i = 0; i < pages.length; i++) {
chunks = [...chunks, chunk * i];
}
}
let i = chunks.findIndex((c) => c > scroll - chunk);
currentColor = hexToRgb(pages[i]?.background);
nextColor = pages[i + 1]?.background ? hexToRgb(pages[i + 1]?.background) : currentColor;
currentChunk = chunks[i];
nextChunk = chunks[i + 1] ?? maxScroll;
colorPerc = ((scroll - currentChunk) / (nextChunk - currentChunk)) * 100;
color = blendRgbColors(currentColor, nextColor, colorPerc / 100);
currentPage = i;
}}
>
<div class="pages">
{#each pages as page, key}
{@const coord = key * chunk}
<div class="page" style={`background-color:${page.background}`}>
<IImage {page} projectId={data.project.id} />
<form method="POST" action="?/delete-file" class="delete-file">
<fieldset role="group">
<input type="text" value={`${page.src}`} name="file" />
<input type="submit" value="Delete page" class="pico-background-red-500" />
</fieldset>
</form>
</div>
<code>{coord}</code>
{/each}
</div>
</article>
{/if}
{/key}
</section>
<style>
h1 {
font-size: 1.5rem;
}
.id {
font-size: 0.5rem;
opacity: 0.3;
}
.iteraction-box {
width: 30px;
height: 30px;
display: block;
background-color: #ff0000;
opacity: 0.3;
position: absolute;
z-index: 100;
}
.blob-image-image {
position: relative;
}
.reader {
display: flex;
width: 80vw;
height: 100vh;
justify-content: center;
padding-top: 5rem;
padding-bottom: 5rem;
margin-bottom: 0;
background-color: var(--bg-color);
overflow-y: scroll;
}
.page {
width: calc(1080px / 3.5);
min-height: calc(1920px / 3.5);
@media (min-width: 1024px) {
width: calc(1080px / 2.5);
min-height: calc(1920px / 2.5);
}
background-color: #fff;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0;
box-shadow: 0rem 1rem 1rem 0rem rgba(0, 0, 0, 0.5);
& form {
margin: 1rem;
margin-bottom: 0;
}
}
.pages {
display: flex;
flex-direction: column;
gap: 1rem;
}
.project {
display: flex;
margin-bottom: 0;
}
.add {
width: 100%;
margin-bottom: 0.5rem;
}
aside {
padding: 1rem;
width: 20vw;
& * {
font-size: 0.8rem !important;
@media (min-width: 1024px) {
font-size: unset;
}
}
}
</style>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { onMount } from 'svelte';
type Page = {
title: string;
src: string;
background: string;
iteraction: Iteraction[];
};
type Iteraction = {
x: number;
y: number;
link: string;
};
export let page: Page;
export let projectId: string;
let image: Element;
let width: number;
let height: number;
let browser = false;
function setCoords() {
let rect = image.getBoundingClientRect();
width = rect.width;
height = rect.height;
}
onMount(() => {
setCoords();
browser = true;
});
</script>
<div style="position: relative;" on:resize={() => setCoords()}>
{#if page.iteraction !== undefined && browser}
{#each page.iteraction as i}
<a
class="iteraction-box"
href={i.link}
target="_blank"
style={[
`margin-left:${(i.x / 100) * width}px;`,
`margin-top:${(i.y / 100) * height}px;`
].join('')}
></a>
{/each}
{/if}
<img bind:this={image} width="1080" height="1920" src={`/files/${projectId}/${page.src}`} />
</div>
<style>
.iteraction-box {
width: 30px;
height: 30px;
display: block;
background-color: #ff0000;
opacity: 0.3;
position: absolute;
z-index: 100;
}
</style>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
svelte.config.js Normal file
View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

1
temp.json Normal file
View File

@@ -0,0 +1 @@
{"id":"88ba21ea","title":"test project","pages":[]}

View File

@@ -1,45 +0,0 @@
{{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,20 +0,0 @@
{{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

@@ -1,18 +0,0 @@
{{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

@@ -1,16 +0,0 @@
{{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}}

View File

@@ -1,32 +0,0 @@
{{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

@@ -1,17 +0,0 @@
{{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}}

View File

@@ -1,75 +0,0 @@
{{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}}

View File

@@ -1,32 +0,0 @@
{{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,77 +0,0 @@
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"
)
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.New("templates").Funcs(functions).ParseFS(embedded, patterns...))
func Templates() *template.Template {
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
}

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 3000,
host: '192.168.1.7'
}
});

1
x

Submodule x deleted from c62be87c6a