189 Commits

Author SHA1 Message Date
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
242559acc9 feat: add tailwindcss 2025-03-06 10:02:35 -03:00
c2bddbf32c chore: ignore .tmp folder 2025-03-06 10:02:13 -03:00
c5043a3527 style: format long line in New constructor 2025-03-06 09:45:01 -03:00
1351cd0cde refactor: import renamed excetion package 2025-03-05 13:52:00 -03:00
51bb55816c chore: update submodule 2025-03-05 13:47:05 -03:00
f13dc0fe0f feat: templates prototype 2025-03-05 13:45:19 -03:00
7b6796e2c1 feat: panic recovering on requests 2025-03-05 12:22:38 -03:00
4875f47f94 chore: rename routes log group to requests 2025-03-05 12:22:21 -03:00
fafd7f76bf chore: hotreloading on file change 2025-03-05 11:00:28 -03:00
489a696e82 feat: exceptions hello world 2025-03-05 10:43:42 -03:00
d2308b5a1b feat: hello world 2025-03-05 10:05:21 -03:00
1edc9c6ff0 chore: update submodule url 2025-03-04 17:42:05 -03:00
4063a6fb0d chore: fresh restart 2025-03-01 19:35:33 -03:00
e16e57f387 chore(deps): add loreddev/x submodule 2025-02-20 09:32:18 -03:00
f1e9468f09 refactor(router): rename parsePath to parsePattern 2024-12-18 14:13:26 -03:00
0af4ea3074 fix(router): trailing slash in mux rest pattern 2024-12-18 14:09:17 -03:00
67230ba75d fix(router): missing join for the rest of the path 2024-12-13 16:59:40 -03:00
c2bbd80dce feat(middlewares): use closures instead of structs for middleware constructors 2024-12-13 16:21:47 -03:00
23ac6d6220 fix(router): missing support for mux server in router constructor 2024-12-13 16:13:26 -03:00
5b5de7b206 feat(router): add support for http methods 2024-12-13 16:12:49 -03:00
b14b0be66b chore(eslint,dev): fix eslint errors in htmx files 2024-10-30 10:35:25 -03:00
a7b5ab174b fix(dev,eslint): remove @eslint/markdown since 'no-irregular-whitespace' breaks with it 2024-10-30 10:34:49 -03:00
478dd0216e feat(cookies): MarshalToWriter helper/alias function 2024-10-24 20:28:31 -03:00
c27b0a4e12 feat(cookies): UnmarshalIfRequest helper/alias function 2024-10-24 20:28:02 -03:00
11312ef5fe refactor(cookies): move errors to bottom of file 2024-10-24 20:12:16 -03:00
d74d71dfa3 feat(cookies,errors): error helper for cookie unmarshaling 2024-10-24 19:53:38 -03:00
6ceef9664a feat(forms): Unmarshaler interface 2024-10-24 19:40:25 -03:00
19cf6e13cc feat(forms,errors): error helper for form and query parsing 2024-10-24 19:39:50 -03:00
dd1d67207d feat(forms): forms and queries parsing and unmarshal 2024-10-24 19:39:11 -03:00
6372b20a0b feat(router,errors): BadRequest error 2024-10-24 19:36:35 -03:00
f27784a0b2 refactor(router,errors): move functiosn to alphabetical order 2024-10-24 19:35:43 -03:00
c05086a93e chore(jsdoc): fix jsdoc in htmx.js and htmx.d.ts files 2024-10-24 17:24:46 -03:00
3ae232a779 feat(cookies): add Marshaler and Unmarshaler interface for cookies 2024-10-24 11:06:01 -03:00
535b7aa975 chore(lint,format): make lint and fmt depend on build/templ 2024-10-23 19:08:03 -03:00
218b991caa refactor(lib): move app routing to lib package
These packages and functions under lib will one day be part of it's
dedicated library/framework, so separating them here helps for the
future.
2024-10-23 19:06:03 -03:00
0e96046259 style(imports,handlers): format imports in handlers 2024-10-23 18:58:13 -03:00
013ed1002a chore(dev,makefile): improve clean command 2024-10-23 18:55:28 -03:00
0ed5f27f92 feat(lib,htmx): install htmx 2024-10-23 18:36:02 -03:00
e769a59b57 refactor(javascript,assets): rename javascript directory to lib 2024-10-23 18:35:38 -03:00
7d49a0fd81 feat(errors,router): add error description in 500 errors 2024-10-22 20:34:35 -03:00
012d0b3137 feat(errors,router): add Endpoint field/data to all errors 2024-10-22 20:34:12 -03:00
1e00971f62 feat(cookies,router): new cookies marshaller and unmarshaller 2024-10-22 20:25:26 -03:00
853c86af0a fix(unocss): rename pages to handlers 2024-10-22 16:58:12 -03:00
96dc9ce119 feat(perf,layouts,css): place global css in style tag for instant loading 2024-10-22 16:57:52 -03:00
3ace8799e2 fix(javascript,assets): js to javascript src link 2024-10-22 16:57:17 -03:00
ea22eedd0e refactor(assets,embedded): rewrite embedded assets files handling 2024-10-22 16:56:50 -03:00
409cb86070 refactor(env,config): create DEV global variable 2024-10-22 16:56:10 -03:00
1034cc4906 refactor(javascript,assets): rename js directory to javascript 2024-10-22 16:55:11 -03:00
390774600f fix(logger,middlewares): status code not being read from response 2024-10-22 11:55:00 -03:00
5448517b67 refactor(pages): move pages to handlers package 2024-10-22 10:09:23 -03:00
50e0ee7bcb fix(dashboard): fix color and fonts 2024-10-22 09:04:20 -03:00
6a561f7d6f feat(dev): development page for testing 2024-10-22 09:03:58 -03:00
fbbbb39fc8 feat(fonts): add Karla and Playfair font families 2024-10-22 09:03:44 -03:00
d65abd3e6f feat(cache,middlewares): cache middlewares 2024-10-22 09:02:58 -03:00
27f29990f5 refactor(router): move Router type to interface 2024-10-21 14:57:53 -03:00
c12db37dc2 refactor(errors,middlwares): new error middleware implementation 2024-10-21 14:27:43 -03:00
a72b2d0482 refactor(router): new router implementation with route groups support 2024-10-21 14:26:46 -03:00
f80dd84784 feat(logger,middlewares): add move information for each request 2024-10-21 14:24:28 -03:00
2219de640d refactor(errors,middlewares): move html displaying to dedicated function 2024-10-18 09:12:45 -03:00
d3bb613252 chore: ignore _templ.txt files 2024-10-18 00:24:10 -03:00
fb97c490a8 fix(errors,middlewares): return plain text on 404 error 2024-10-18 00:23:42 -03:00
d74a13bfd6 fix(errors,middlewares): Accept header prefersHtml boolean operator 2024-10-18 00:23:09 -03:00
c55a516a3d feat(errors,middlwares): add error handler middleware 2024-10-17 23:47:35 -03:00
f3f060ddc8 feat(middlewares): middleware struct interface 2024-10-17 23:46:52 -03:00
65e34b4e29 feat(logger,middlewares): add group do logger in logger middleware 2024-10-17 23:46:08 -03:00
5157deda2a chore(dev): move final binary from dist to .dist directory 2024-10-15 15:33:16 -03:00
35dbf51fb0 feat(dev): development pages for testing and prototypes 2024-10-15 01:04:35 -03:00
8a44e0821d feat(ui,theme): dynamic theme css 2024-10-15 01:04:18 -03:00
bfd24e0dc7 feat(layouts): base layout for all pages 2024-10-15 01:03:39 -03:00
a183a5a069 feat(dev): development middleware 2024-10-15 01:02:31 -03:00
8a4a7f06ad feat(dev): logging middleware 2024-10-15 01:01:47 -03:00
0222d191e9 fix: check errors in error ServeHTTP 2024-10-15 00:59:43 -03:00
4ffe2f4e9e refactor: move Middleware interface to sinle function class 2024-10-15 00:57:06 -03:00
8388e2763e fix(dev): ignore json files from linting 2024-10-13 20:29:44 -03:00
36ab51b337 chore(dev): eslint command to make lint 2024-10-13 20:18:56 -03:00
cfac969a12 feat: hello world js test 2024-10-13 20:18:25 -03:00
09e1a0ce1e chore(dev): eslint configuration
I love Javascript :)
2024-10-13 20:18:04 -03:00
d9eb24b9cd feat(dev): debugger configuration 2024-10-11 22:30:13 -03:00
78718c29bf feat(app): static file serving 2024-10-11 22:19:07 -03:00
e70e18aee5 chore(deps): use eslint_d instead of eslint 2024-10-11 14:13:44 -03:00
518c712038 fix(dev): hot reloading 2024-10-11 14:13:34 -03:00
fe427c0394 fix: commands in makefile 2024-10-07 19:16:55 -03:00
a01742828e fix: incorrect fields in Route 2024-10-07 19:16:44 -03:00
34c6b7ccb4 feat: test page 2024-10-07 19:12:27 -03:00
f1d312bfce chore: configure makefile for development 2024-10-07 19:12:06 -03:00
32d393c1da feat(deps): add unocss 2024-10-07 19:11:45 -03:00
4f423794a3 chore(deps): add templ 2024-10-07 15:40:38 -03:00
47c570855f feat(router): error handling of routes 2024-10-07 15:40:26 -03:00
1c7131c216 feat(router): support for middlewares 2024-10-07 15:40:06 -03:00
bb0c4b371a feat: setup project router 2024-10-07 15:39:46 -03:00
9cd1e1b1e9 chore: add golang tools to flake.niix 2024-10-04 19:41:07 -03:00
67 changed files with 2805 additions and 987 deletions

View File

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

26
.gitignore vendored
View File

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

3
.gitmodules vendored Normal file
View File

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

20
.golangci.yml Normal file
View File

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

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

View File

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

View File

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

26
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"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"
]
}
]
}

View File

@@ -1,38 +0,0 @@
# 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.

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";

BIN
bun.lockb

Binary file not shown.

163
cmd/cmd.go Normal file
View File

@@ -0,0 +1,163 @@
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()
assertions := tinyssert.NewDisabledAssertions()
if *dev {
assertions = tinyssert.NewAssertions(tinyssert.Opts{
Panic: true,
})
}
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}))
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)
}

176
comicverse.go Normal file
View File

@@ -0,0 +1,176 @@
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/database"
"forge.capytal.company/capytalcode/project-comicverse/internals/joinedfs"
"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.NewAssertions(),
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)
var err error
database, err := database.New(database.Config{
SQL: app.db,
Context: app.ctx,
Assertions: app.assert,
Logger: app.logger.WithGroup("database"),
})
if err != nil {
return errors.Join(errors.New("unable to create database struct"), err)
}
service, err := service.New(service.Config{
DB: database,
S3: app.s3,
Bucket: app.bucket,
Context: app.ctx,
Assertions: app.assert,
Logger: app.logger.WithGroup("service"),
})
if err != nil {
return errors.Join(errors.New("unable to initiate service"), err)
}
app.handler, err = router.New(router.Config{
Service: service,
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)
}

98
database/database.go Normal file
View File

@@ -0,0 +1,98 @@
package database
import (
"context"
"database/sql"
"errors"
"log/slog"
"forge.capytal.company/loreddev/x/tinyssert"
)
var ErrNoRows = sql.ErrNoRows
type Database struct {
sql *sql.DB
ctx context.Context
assert tinyssert.Assertions
log *slog.Logger
}
func New(cfg Config) (*Database, error) {
if cfg.SQL == nil {
return nil, errors.New("SQL database interface should not be nil")
}
if cfg.Context == nil {
return nil, errors.New("context interface should not be nil")
}
if cfg.Assertions == nil {
return nil, errors.New("assertions interface should not be nil")
}
if cfg.Logger == nil {
return nil, errors.New("logger should not be a nil pointer")
}
db := &Database{
sql: cfg.SQL,
ctx: cfg.Context,
assert: cfg.Assertions,
log: cfg.Logger,
}
if err := db.setup(); err != nil {
return nil, errors.Join(errors.New("error while setting up Database struct"), err)
}
return db, nil
}
type Config struct {
SQL *sql.DB
Context context.Context
Assertions tinyssert.Assertions
Logger *slog.Logger
}
func (db *Database) setup() error {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
log := db.log
log.Info("Setting up database")
log.Debug("Pinging database")
err := db.sql.PingContext(db.ctx)
if err != nil {
return errors.Join(errors.New("unable to ping database"), err)
}
log.Debug("Creating tables")
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return errors.Join(errors.New("unable to start transaction to create tables"), err)
}
setups := []func(*sql.Tx) error{
db.setupProjects,
}
for _, setup := range setups {
err := setup(tx)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return errors.Join(errors.New("unable to run transaction to create tables"), err)
}
return nil
}

156
database/projects.go Normal file
View File

@@ -0,0 +1,156 @@
package database
import (
"database/sql"
"errors"
"fmt"
"log/slog"
)
type Project struct {
ID string
Title string
}
func (db *Database) setupProjects(tx *sql.Tx) error {
db.assert.NotNil(tx)
db.assert.NotNil(db.ctx)
q := `CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL
) STRICT`
_, err := tx.ExecContext(db.ctx, q)
if err != nil {
return errors.Join(errors.New(`unable to execute create query to table "projects"`), err)
}
return nil
}
func (db *Database) CreateProject(id string, title string) (Project, error) {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
db.assert.NotZero(id)
db.assert.NotZero(title)
q := fmt.Sprintf(`INSERT INTO projects (id, title) VALUES ('%s', '%s')`, id, title)
db.log.Debug("Inserting into Projects", slog.String("query", q))
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return Project{}, err
}
_, err = tx.ExecContext(db.ctx, q)
if err != nil {
return Project{}, err
}
err = tx.Commit()
if err != nil {
return Project{}, err
}
return Project{ID: id, Title: title}, nil
}
func (db *Database) GetProject(id string) (Project, error) {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
q := fmt.Sprintf(`SELECT id, title FROM projects WHERE id = '%s'`, id)
db.log.Debug("Getting Project", slog.String("query", q))
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return Project{}, err
}
row := tx.QueryRowContext(db.ctx, q)
p := Project{}
err = row.Scan(&p.ID, &p.Title)
if err != nil {
return p, err
}
err = tx.Commit()
if err != nil {
return p, err
}
return p, nil
}
func (db *Database) ListProjects() ([]Project, error) {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
q := `SELECT id, title FROM projects`
db.log.Debug("Listing Projects", slog.String("query", q))
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return []Project{}, err
}
rows, err := tx.QueryContext(db.ctx, q)
if err != nil {
db.assert.Nil(tx.Rollback())
return []Project{}, err
}
ps := []Project{}
for rows.Next() {
p := Project{}
err := rows.Scan(&p.ID, &p.Title)
if err != nil {
db.assert.Nil(tx.Rollback())
return ps, err
}
ps = append(ps, p)
}
err = tx.Commit()
if err != nil {
return ps, err
}
return ps, nil
}
func (db *Database) DeleteProject(id string) error {
db.assert.NotNil(db.sql)
db.assert.NotNil(db.ctx)
db.assert.NotNil(db.log)
db.assert.NotZero(id)
q := fmt.Sprintf(`DELETE FROM projects WHERE id = '%s'`, id)
db.log.Debug("Deleting from Projects", slog.String("query", q))
tx, err := db.sql.BeginTx(db.ctx, nil)
if err != nil {
return err
}
_, err = tx.ExecContext(db.ctx, q)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}

View File

@@ -1,33 +0,0 @@
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": 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

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

25
go.mod Normal file
View File

@@ -0,0 +1,25 @@
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/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92
)
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
)

40
go.sum Normal file
View File

@@ -0,0 +1,40 @@
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/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/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=

6
go.work Normal file
View File

@@ -0,0 +1,6 @@
go 1.24.1
use (
./.
./x
)

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
}

46
makefile Normal file
View File

@@ -0,0 +1,46 @@
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
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,38 +0,0 @@
{
"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"
}

283
router/editor.go Normal file
View File

@@ -0,0 +1,283 @@
package router
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"forge.capytal.company/capytalcode/project-comicverse/internals/randstr"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/loreddev/x/smalltrip/exception"
)
func (router *router) pages(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
// TODO: Check if project exists
id := r.PathValue("ID")
if id == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
ServeHTTP(w, r)
return
}
pageID := r.PathValue("PageID")
switch getMethod(r) {
case http.MethodGet, http.MethodHead:
if pageID == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "PageID" must be provided`)).
ServeHTTP(w, r)
return
}
router.getPage(w, r)
case http.MethodPost:
router.addPage(w, r)
case http.MethodDelete:
if pageID == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "PageID" must be provided`)).
ServeHTTP(w, r)
return
}
router.deletePage(w, r)
default:
exception.
MethodNotAllowed([]string{
http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodDelete,
}).
ServeHTTP(w, r)
}
}
func (router *router) addPage(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
img, _, err := r.FormFile("image")
if err != nil {
// TODO: Handle if the file is bigger than allowed by ParseForm (10mb)
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
err = router.service.AddPage(id, img)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
}
func (router *router) getPage(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
pageID := r.PathValue("PageID")
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
page, err := router.service.GetPage(id, pageID)
if errors.Is(err, service.ErrPageNotExists) {
exception.NotFound(exception.WithError(err)).ServeHTTP(w, r)
return
}
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
if i, ok := page.Image.(io.WriterTo); ok {
_, err = i.WriteTo(w)
} else {
_, err = io.Copy(w, page.Image)
}
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
}
func (router *router) deletePage(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
pageID := r.PathValue("PageID")
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
err := router.service.DeletePage(id, pageID)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
}
func (router *router) interactions(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
// TODO: Check if the project exists
id := r.PathValue("ID")
if id == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
ServeHTTP(w, r)
return
}
// TODO: Check if page exists
pageID := r.PathValue("PageID")
if pageID == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "PageID" must be provided`)).
ServeHTTP(w, r)
return
}
interactionID := r.PathValue("InteractionID")
switch getMethod(r) {
case http.MethodPost:
router.addInteraction(w, r)
case http.MethodDelete:
if interactionID == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "InteractionID" must be provided`)).
ServeHTTP(w, r)
return
}
router.deleteInteraction(w, r)
default:
exception.
MethodNotAllowed([]string{
http.MethodPost,
http.MethodDelete,
}).
ServeHTTP(w, r)
}
}
func (router *router) addInteraction(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
pageID := r.PathValue("PageID")
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
// TODO: Methods to manipulate interactions, instead of router need to do this logic
page, err := router.service.GetPage(id, pageID)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
page.Image = nil // HACK: Prevent image update on S3
x, err := strconv.ParseUint(r.FormValue("x"), 10, 0)
if err != nil {
exception.
BadRequest(errors.Join(errors.New(`value "x" should be a valid non-negative integer`), err)).
ServeHTTP(w, r)
return
}
y, err := strconv.ParseUint(r.FormValue("y"), 10, 0)
if err != nil {
exception.
BadRequest(errors.Join(errors.New(`value "y" should be a valid non-negative integer`), err)).
ServeHTTP(w, r)
return
}
link := r.FormValue("link")
if link == "" {
exception.BadRequest(errors.New(`missing parameter "link" in request`)).ServeHTTP(w, r)
return
}
intID, err := randstr.NewHex(6)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
page.Interactions[intID] = service.PageInteraction{
X: uint16(x),
Y: uint16(y),
URL: link,
}
err = router.service.UpdatePage(id, page)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
}
func (router *router) deleteInteraction(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
id := r.PathValue("ID")
router.assert.NotZero(id, "This method should be used after the path values are checked")
pageID := r.PathValue("PageID")
router.assert.NotZero(pageID, "This method should be used after the path values are checked")
interactionID := r.PathValue("InteractionID")
router.assert.NotZero(interactionID, "This method should be used after the path values are checked")
// TODO: Methods to manipulate interactions, instead of router need to do this logic
page, err := router.service.GetPage(id, pageID)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
page.Image = nil // HACK: Prevent image update on S3
delete(page.Interactions, interactionID)
err = router.service.UpdatePage(id, page)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/projects/%s/", id), http.StatusSeeOther)
}

181
router/projects.go Normal file
View File

@@ -0,0 +1,181 @@
package router
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path"
"forge.capytal.company/capytalcode/project-comicverse/service"
"forge.capytal.company/loreddev/x/smalltrip/exception"
)
func (router *router) projects(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
switch getMethod(r) {
case http.MethodGet, http.MethodHead:
if id := r.PathValue("ID"); id != "" {
router.getProject(w, r)
} else {
router.listProjects(w, r)
}
case http.MethodPost:
router.createProject(w, r)
case http.MethodDelete:
if id := r.PathValue("ID"); id != "" {
router.deleteProject(w, r)
} else {
exception.
BadRequest(errors.New(`missing "ID" path value`)).
ServeHTTP(w, r)
}
default:
exception.MethodNotAllowed([]string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodDelete,
}).ServeHTTP(w, r)
}
}
func (router *router) createProject(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
if getMethod(r) != http.MethodPost {
exception.
MethodNotAllowed([]string{http.MethodPost}).
ServeHTTP(w, r)
return
}
p, err := router.service.CreateProject()
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
router.assert.NotZero(p.ID)
http.Redirect(w, r, fmt.Sprintf("%s/", path.Join(r.URL.Path, p.ID)), http.StatusSeeOther)
}
func (router *router) getProject(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
router.assert.NotNil(router.templates)
if getMethod(r) != http.MethodGet && getMethod(r) != http.MethodHead {
exception.
MethodNotAllowed([]string{http.MethodGet, http.MethodHead}).
ServeHTTP(w, r)
return
}
id := r.PathValue("ID")
if id == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
ServeHTTP(w, r)
return
}
p, err := router.service.GetProject(id)
switch {
case errors.Is(err, service.ErrProjectNotExists):
exception.NotFound().ServeHTTP(w, r)
return
case err != nil:
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
err = router.templates.ExecuteTemplate(w, "project", p)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
}
func (router *router) listProjects(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
router.assert.NotNil(router.templates)
if getMethod(r) != http.MethodGet && getMethod(r) != http.MethodHead {
exception.
MethodNotAllowed([]string{http.MethodGet, http.MethodHead}).
ServeHTTP(w, r)
return
}
ps, err := router.service.ListProjects()
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
b, err := json.Marshal(ps)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write(b)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
}
func (router *router) deleteProject(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(w)
router.assert.NotNil(r)
router.assert.NotNil(router.service)
router.assert.NotNil(router.templates)
if getMethod(r) != http.MethodDelete {
exception.
MethodNotAllowed([]string{http.MethodDelete}).
ServeHTTP(w, r)
return
}
id := r.PathValue("ID")
if id == "" {
exception.
BadRequest(fmt.Errorf(`a valid path value of "ID" must be provided`)).
ServeHTTP(w, r)
return
}
err := router.service.DeleteProject(id)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
err = router.templates.ExecuteTemplate(w, "partials-status", map[string]any{
"StatusCode": http.StatusOK,
"Message": fmt.Sprintf("Project %q successfully deleted", id),
"Redirect": "/dashboard/",
"RedirectMessage": "Go back to dashboard",
})
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
}

138
router/router.go Normal file
View File

@@ -0,0 +1,138 @@
package router
import (
"errors"
"io/fs"
"log/slog"
"net/http"
"strings"
"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 {
service *service.Service
templates templates.ITemplate
assets fs.FS
cache bool
assert tinyssert.Assertions
log *slog.Logger
}
func New(cfg Config) (http.Handler, error) {
if cfg.Service == nil {
return nil, errors.New("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{
service: cfg.Service,
templates: cfg.Templates,
assets: cfg.Assets,
cache: !cfg.DisableCache,
assert: cfg.Assertions,
log: cfg.Logger,
}
return r.setup(), nil
}
type Config struct {
Service *service.Service
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())
r.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.HandleFunc("/dashboard/", router.dashboard)
r.HandleFunc("/projects/{$}", router.projects)
r.HandleFunc("/projects/{ID}/", router.projects)
r.HandleFunc("/projects/{ID}/pages/{$}", router.pages)
r.HandleFunc("/projects/{ID}/pages/{PageID}", router.pages)
r.HandleFunc("/projects/{ID}/pages/{PageID}/interactions/{$}", router.interactions)
r.HandleFunc("/projects/{ID}/pages/{PageID}/interactions/{InteractionID}", router.interactions)
return r
}
func (router *router) dashboard(w http.ResponseWriter, r *http.Request) {
router.assert.NotNil(router.templates)
router.assert.NotNil(router.service)
router.assert.NotNil(w)
router.assert.NotNil(r)
p, err := router.service.ListProjects()
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
return
}
w.WriteHeader(http.StatusOK)
err = router.templates.ExecuteTemplate(w, "dashboard", p)
if err != nil {
exception.InternalServerError(err).ServeHTTP(w, r)
}
}
func getMethod(r *http.Request) string {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return r.Method
}
m := r.FormValue("x-method")
if m == "" {
return r.Method
}
return strings.ToUpper(m)
}

180
service/editor.go Normal file
View File

@@ -0,0 +1,180 @@
package service
import (
"errors"
"fmt"
"io"
"net/http"
"slices"
"forge.capytal.company/capytalcode/project-comicverse/internals/randstr"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
const pageIDLength = 6
var ErrPageNotExists = errors.New("page does not exists in storage")
type Project struct {
ID string `json:"id"`
Title string `json:"title"`
Pages []ProjectPage `json:"pages"`
}
type ProjectPage struct {
ID string `json:"id"`
Interactions map[string]PageInteraction `json:"interactions"`
Image io.ReadCloser `json:"-"`
}
type PageInteraction struct {
URL string `json:"url"`
X uint16 `json:"x"`
Y uint16 `json:"y"`
}
func (s *Service) AddPage(projectID string, img io.Reader) error {
s.assert.NotNil(s.ctx)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.bucket)
s.assert.NotZero(projectID)
s.assert.NotNil(img)
id, err := randstr.NewHex(pageIDLength)
if err != nil {
return err
}
p, err := s.GetProject(projectID)
if err != nil {
return errors.Join(errors.New("unable to get project"), err)
}
p.Pages = append(p.Pages, ProjectPage{ID: id, Interactions: map[string]PageInteraction{}})
k := fmt.Sprintf("%s/%s", projectID, id)
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
Key: &k,
Body: img,
Bucket: &s.bucket,
})
if err != nil {
return err
}
err = s.UpdateProject(projectID, p)
return err
}
func (s *Service) GetPage(projectID string, pageID string) (ProjectPage, error) {
s.assert.NotNil(s.ctx)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.bucket)
s.assert.NotZero(projectID)
s.assert.NotNil(pageID)
p, err := s.GetProject(projectID)
if err != nil {
return ProjectPage{}, errors.Join(errors.New("unable to get project"), err)
}
pageIndex := slices.IndexFunc(p.Pages, func(p ProjectPage) bool { return p.ID == pageID })
if pageIndex == -1 {
return ProjectPage{}, ErrPageNotExists
}
page := p.Pages[pageIndex]
k := fmt.Sprintf("%s/%s", projectID, pageID)
res, err := s.s3.GetObject(s.ctx, &s3.GetObjectInput{
Key: &k,
Bucket: &s.bucket,
})
if err != nil {
var resErr *awshttp.ResponseError
if errors.As(err, &resErr) && resErr.ResponseError.HTTPStatusCode() == http.StatusNotFound {
// TODO: This would probably be better in some background "maintenance" worker
p.Pages = slices.Delete(p.Pages, pageIndex, pageIndex)
_ = s.UpdateProject(projectID, p)
return ProjectPage{}, errors.Join(ErrPageNotExists, resErr)
}
return ProjectPage{}, err
}
s.assert.NotNil(res.Body)
s.assert.NotNil(page.Interactions)
page.Image = res.Body
return page, nil
}
func (s *Service) UpdatePage(projectID string, page ProjectPage) error {
s.assert.NotNil(s.ctx)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.bucket)
s.assert.NotZero(projectID)
s.assert.NotZero(page.ID)
s.assert.NotNil(page.Interactions)
p, err := s.GetProject(projectID)
if err != nil {
return errors.Join(errors.New("unable to get project"), err)
}
pageIndex := slices.IndexFunc(p.Pages, func(p ProjectPage) bool { return p.ID == page.ID })
if pageIndex == -1 {
return ErrPageNotExists
}
p.Pages[pageIndex] = page
// TODO: Probably a "lastUpdated" timestamp in the ProjectPage data would be better
// so we don't update equal images. Changing the image in ProjectPage would be better
// using a method, or could be completely decoupled from the struct.
if page.Image != nil {
k := fmt.Sprintf("%s/%s", projectID, page.ID)
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
Key: &k,
Body: page.Image,
Bucket: &s.bucket,
})
if err != nil {
return errors.Join(errors.New("error while trying to update image"), err)
}
}
err = s.UpdateProject(projectID, p)
if err != nil {
return errors.Join(errors.New("error while trying to update project"), err)
}
return nil
}
func (s *Service) DeletePage(projectID string, id string) error {
s.assert.NotNil(s.ctx)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.bucket)
s.assert.NotZero(projectID)
s.assert.NotNil(id)
p, err := s.GetProject(projectID)
if err != nil {
return errors.Join(errors.New("unable to get project"), err)
}
k := fmt.Sprintf("%s/%s", projectID, id)
_, err = s.s3.DeleteObject(s.ctx, &s3.DeleteObjectInput{
Key: &k,
Bucket: &s.bucket,
})
if err != nil {
return err
}
p.Pages = slices.DeleteFunc(p.Pages, func(p ProjectPage) bool { return p.ID == id })
err = s.UpdateProject(projectID, p)
return err
}

196
service/projects.go Normal file
View File

@@ -0,0 +1,196 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"forge.capytal.company/capytalcode/project-comicverse/database"
"forge.capytal.company/capytalcode/project-comicverse/internals/randstr"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
const projectIDLength = 6
var ErrProjectNotExists = errors.New("project does not exists in database")
func (s *Service) CreateProject() (Project, error) {
s.assert.NotNil(s.db)
s.assert.NotNil(s.s3)
s.assert.NotNil(s.ctx)
s.assert.NotZero(s.bucket)
s.log.Debug("Creating new project")
id, err := randstr.NewHex(projectIDLength)
if err != nil {
return Project{}, errors.Join(errors.New("creating hexadecimal ID returned error"), err)
}
title := "New Project"
s.assert.NotZero(id, "ID should never be empty")
s.log.Debug("Creating project on database", slog.String("id", id))
_, err = s.db.CreateProject(id, title)
if err != nil {
return Project{}, err
}
p := Project{
ID: id,
Title: title,
Pages: []ProjectPage{},
}
c, err := json.Marshal(p)
if err != nil {
return Project{}, err
}
s.log.Debug("Creating project on storage", slog.String("id", id))
f := fmt.Sprintf("%s.comic.json", id)
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Key: &f,
Body: bytes.NewReader(c),
})
if err != nil {
return Project{}, err
}
return p, nil
}
func (s *Service) GetProject(id string) (Project, error) {
s.assert.NotNil(s.db)
s.assert.NotNil(s.s3)
s.assert.NotZero(s.bucket)
s.assert.NotNil(s.ctx)
s.assert.NotZero(id)
res, err := s.db.GetProject(id)
if errors.Is(err, database.ErrNoRows) {
return Project{}, errors.Join(ErrProjectNotExists, err)
}
if err != nil {
return Project{}, err
}
f := fmt.Sprintf("%s.comic.json", id)
file, err := s.s3.GetObject(s.ctx, &s3.GetObjectInput{
Bucket: &s.bucket,
Key: &f,
})
if err != nil {
return Project{}, err
}
c, err := io.ReadAll(file.Body)
if err != nil {
return Project{}, err
}
var p Project
err = json.Unmarshal(c, &p)
s.assert.Equal(res.ID, p.ID, "The project ID should always be equal in the Database and Storage")
s.assert.Equal(res.Title, p.Title)
return p, err
}
func (s *Service) ListProjects() ([]Project, error) {
s.assert.NotNil(s.db)
ps, err := s.db.ListProjects()
if err != nil {
return []Project{}, err
}
p := make([]Project, len(ps))
for i, dp := range ps {
// TODO: this is temporally for debugging, getting every project
// from s3 can be expensive
v, err := s.GetProject(dp.ID)
if err != nil {
return []Project{}, err
}
p[i] = v
}
return p, nil
}
func (s *Service) UpdateProject(id string, project Project) error {
s.assert.NotNil(s.db)
s.assert.NotNil(s.s3)
s.assert.NotZero(s.bucket)
s.assert.NotNil(s.ctx)
s.assert.NotZero(id)
c, err := json.Marshal(project)
if err != nil {
return err
}
s.log.Debug("Updating project on storage", slog.String("id", id))
f := fmt.Sprintf("%s.comic.json", id)
_, err = s.s3.PutObject(s.ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Body: bytes.NewReader(c),
Key: &f,
})
if err != nil {
return err
}
return nil
}
func (s *Service) DeleteProject(id string) error {
s.assert.NotNil(s.db)
s.assert.NotNil(s.s3)
s.assert.NotZero(s.bucket)
s.assert.NotNil(s.ctx)
s.assert.NotZero(id)
p, err := s.GetProject(id)
if err != nil {
return errors.Join(errors.New("unable to get information of project"), err)
}
err = s.db.DeleteProject(id)
if err != nil {
return err
}
s.log.Debug("Deleting project on storage", slog.String("id", id))
files := []types.ObjectIdentifier{}
f := fmt.Sprintf("%s.comic.json", id)
files = append(files, types.ObjectIdentifier{Key: &f})
for k := range p.Pages {
f := fmt.Sprintf("%s/%s", id, k)
files = append(files, types.ObjectIdentifier{Key: &f})
}
_, err = s.s3.DeleteObjects(s.ctx, &s3.DeleteObjectsInput{
Delete: &types.Delete{Objects: files},
Bucket: &s.bucket,
})
if err != nil {
return err
}
return nil
}

64
service/service.go Normal file
View File

@@ -0,0 +1,64 @@
package service
import (
"context"
"errors"
"log/slog"
"forge.capytal.company/capytalcode/project-comicverse/database"
"forge.capytal.company/loreddev/x/tinyssert"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type Service struct {
db *database.Database
s3 *s3.Client
bucket string
ctx context.Context
assert tinyssert.Assertions
log *slog.Logger
}
func New(cfg Config) (*Service, error) {
if cfg.DB == nil {
return nil, errors.New("database should not be a nil pointer")
}
if cfg.S3 == nil {
return nil, errors.New("s3 client should not be a nil pointer")
}
if cfg.Bucket == "" {
return nil, errors.New("bucket should not be a empty string")
}
if cfg.Context == nil {
return nil, errors.New("context should not be a nil interface")
}
if cfg.Assertions == nil {
return nil, errors.New("assertions should not be a nil interface")
}
if cfg.Logger == nil {
return nil, errors.New("logger should not be a nil pointer")
}
return &Service{
db: cfg.DB,
s3: cfg.S3,
bucket: cfg.Bucket,
ctx: cfg.Context,
assert: cfg.Assertions,
log: cfg.Logger,
}, nil
}
type Config struct {
DB *database.Database
S3 *s3.Client
Bucket string
Context context.Context
Assertions tinyssert.Assertions
Logger *slog.Logger
}

13
src/app.d.ts vendored
View File

@@ -1,13 +0,0 @@
// 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 {};

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

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

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

@@ -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}}

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}}

77
templates/templates.go Normal file
View File

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

View File

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

View File

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

Submodule x added at ceda7536f1