1 Commits

56 changed files with 612 additions and 4081 deletions

View File

@@ -132,7 +132,9 @@ class IPUBBody extends IPUBElement {
IPUBImage,
IPUBInteraction,
IPUBSoundtrack,
IPUBTrigger,
IPUBTrack,
IPUBTrackItem,
IPUBTrackItemPosition,
]) {
console.info(`IPUBBody: Defining custom element <${e.elementName}>`);
globalThis.customElements.define(e.elementName, e);
@@ -210,6 +212,18 @@ class IPUBCover extends IPUBElement {
}
}
class IPUBTrack extends IPUBElement {
static elementName = "ipub-track";
}
class IPUBTrackItem extends IPUBElement {
static elementName = `ipub-track-item`;
}
class IPUBTrackItemPosition extends IPUBElement {
static elementName = `ipub-track-item-position`;
}
class IPUBAudio extends IPUBElement {
static elementName = "ipub-audio";
@@ -283,7 +297,6 @@ class IPUBAudio extends IPUBElement {
}
if (this.#isFading) {
// TODO: Be able to force fading to be canceled
return;
}
@@ -555,7 +568,7 @@ class IPUBSoundtrack extends IPUBElement {
`IPUBSoundtrack: error while trying to play audio, error: ${e}`,
{
error: e,
audio: last,
audio: audio,
},
);
}
@@ -565,32 +578,23 @@ class IPUBSoundtrack extends IPUBElement {
* @private
*/
static #observer = (() => {
return new IntersectionObserver(
(entries) => {
for (const { intersectionRatio, target, time } of entries) {
/** @type {IPUBSoundtrack} */
const soundtrack =
target.tagName === IPUBTrigger.elementName
? getAncestor(target, IPUBSoundtrack.elementName)
: target;
return new IntersectionObserver((entries) => {
for (const { intersectionRatio, target, time } of entries) {
/** @type {IPUBSoundtrack} */
const soundtrack = target;
if (intersectionRatio === 1) {
console.debug(
`${soundtrack.id} is on screen at ${time}`,
soundtrack,
);
this.#onScreenStack.add(soundtrack);
} else {
console.debug(
`${soundtrack.id} is not on screen ${time}`,
soundtrack,
);
this.#onScreenStack.delete(soundtrack);
}
if (intersectionRatio > 0) {
console.debug(`${soundtrack.id} is on screen at ${time}`, soundtrack);
this.#onScreenStack.add(soundtrack);
} else {
console.debug(
`${soundtrack.id} is not on screen ${time}`,
soundtrack,
);
this.#onScreenStack.delete(soundtrack);
}
},
{ threshold: 1 },
);
}
});
})();
/**
@@ -648,142 +652,10 @@ class IPUBSoundtrack extends IPUBElement {
return;
}
const trigger = this.querySelector(IPUBTrigger.elementName);
if (trigger) {
IPUBSoundtrack.#observer.observe(trigger);
} else {
IPUBSoundtrack.#observer.observe(this);
}
IPUBSoundtrack.#observer.observe(this);
}
// TODO(guz013): Handle if element is moved, it's group should be updated
// TODO(guz013): Handle if element is deleted/disconnected, it should be removed from observer
}
class IPUBTrigger extends IPUBElement {
static elementName = "ipub-trigger";
static observedAttributes = ["height", "width"].concat(
IPUBElement.observedAttributes,
);
// TODO: Make this observer global
/** @private */
static #resizeObserver = new ResizeObserver((bodies) => {
for (const { target: body, contentRect } of bodies) {
const height = Math.max(body.scrollHeight, contentRect.height);
const width = Math.max(body.scrollWidth, contentRect.width);
for (const trigger of IPUBTrigger.#resizableTriggers.get(body)) {
const percH = trigger.getAttribute("height");
if (percH) {
trigger.style.setProperty(
"--ipub-height",
`${Math.round((height / 100) * Number.parseFloat(percH))}px`,
);
}
const percW = trigger.getAttribute("width");
if (percW) {
trigger.style.setProperty(
"--ipub-width",
`${Math.round((width / 100) * Number.parseFloat(percW))}px`,
);
}
}
}
});
/**
* @private
* @type {Map<IPUBBody, Set<IPUBTrigger>>}
*/
static #resizableTriggers = new Map();
// FIXME: trigger can be the same size as viewport, cap it to 80% of viewport
// height and 100% of viewport width
connectedCallback() {
super.connectedCallback();
const body = getAncestor(this, "ipub-body");
if (!body) {
console.error("IPUBTrigger: element must be a descendant of ipub-body");
return;
}
console.debug(
`IPUBTrigger#${this.id}: adding ${IPUBBody.elementName}#${body.id} from resize observer`,
);
IPUBTrigger.#resizeObserver.observe(body);
if (this.getAttribute("height") || this.getAttribute("width")) {
IPUBTrigger.#resizableTriggers.set(
body,
(IPUBTrigger.#resizableTriggers.get(body) || new Set()).add(this),
);
}
}
attributeChangedCallback(name, oldValue, newValue) {
super.attributeChangedCallback(name, oldValue, newValue);
const body = getAncestor(this, "ipub-body");
if (!body) {
console.error("IPUBTrigger: element must be a descendant of ipub-body");
return;
}
const set = IPUBTrigger.#resizableTriggers.get(body) || new Set();
if (this.getAttribute("height") || this.getAttribute("width")) {
set.add(this);
} else {
set.delete(this);
}
if (name === "width" || name === "height") {
const height = Math.max(
body.scrollHeight,
body.getBoundingClientRect().height,
);
const width = Math.max(
body.scrollWidth,
body.getBoundingClientRect().width,
);
const percH = this.getAttribute("height");
if (percH) {
this.style.setProperty(
"--ipub-height",
`${Math.round((height / 100) * Number.parseFloat(percH))}px`,
);
}
const percW = this.getAttribute("width");
if (percW) {
this.style.setProperty(
"--ipub-width",
`${Math.round((width / 100) * Number.parseFloat(percW))}px`,
);
}
}
}
disconnectedCallback() {
const set = IPUBTrigger.#resizableTriggers.get(body) || new Set();
set.delete(this);
if (set.size === 0) {
const body = getAncestor(this, "ipub-body");
if (!body) {
console.error("IPUBTrigger: element must be a descendant of ipub-body");
return;
}
console.debug(
`IPUBTrigger#${this.id}: removing ${IPUBBody.elementName}#${body.id} from resize observer`,
);
IPUBTrigger.#resizableTriggers.delete(body);
IPUBTrigger.#resizeObserver.unobserve(body);
}
}
}
/**

View File

@@ -24,95 +24,126 @@
</dialog>
</ipub-cover>
<main id="content">
<ipub-background id="background0001">
<img src="../images/background0001.jpg" width="100" height="100" />
</ipub-background>
<ipub-soundtrack style="--ipub-color:cyan">
<ipub-trigger height="10" />
<!-- TODO: Search on how to make this more accessible, more semantic as using <details> -->
<figure>
<label>
<input type="checkbox" />
<figcaption>Soundtrack 1</figcaption>
</label>
<ipub-audio>
<audio controls="true" volume="0" controlslist="nofullscreen"
disableremoteplayback="">
<source src="../audios/track1.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
<ipub-image>
<img src="../images/image0001.png" />
<ipub-interaction style="--ipub-y:88.5%;--ipub-x:6%" circle="">
<a href="https://krita.org" referrerpolicy="same-origin"
rel="external nofollow noopener noreferrer" target="_blank" />
</ipub-interaction>
<ipub-interaction style="--ipub-y:93.5%;--ipub-x:81.5%;--ipub-size:13%;">
<a href="https://guz.one" referrerpolicy="same-origin"
rel="external nofollow noopener noreferrer" target="_blank" />
</ipub-interaction>
</ipub-image>
<ipub-image>
<img src="../images/image0002.png" />
</ipub-image>
<ipub-soundtrack style="--ipub-color:green;">
<ipub-trigger height="10" />
<figure>
<label>
<input type="checkbox" />
<figcaption>Soundtrack 2</figcaption>
</label>
<ipub-audio>
<audio controls="true" volume="0" controlslist="nofullscreen"
disableremoteplayback="">
<source src="../audios/track2.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
<ipub-background id="background0002">
<picture>
<img src="../images/background0002.jpg" />
</picture>
</ipub-background>
<ipub-image>
<img src="../images/image0003.png" />
</ipub-image>
<ipub-image>
<img src="../images/image0004.png" />
</ipub-image>
<ipub-background id="background0003">
<picture>
<img src="../images/background0003.jpg" />
</picture>
</ipub-background>
<ipub-image>
<img src="../images/image0002.png" />
</ipub-image>
<ipub-soundtrack style="--ipub-color:yellow;">
<ipub-trigger height="10" />
<figure>
<label>
<input type="checkbox" />
<figcaption>Soundtrack 3</figcaption>
</label>
<ipub-audio>
<audio controls="true" volume="0" controlslist="nofullscreen"
disableremoteplayback="">
<source src="../audios/track3.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
<ipub-image>
<img src="../images/image0003.png" />
</ipub-image>
<ipub-image>
<img src="../images/image0004.png" />
</ipub-image>
</main>
<ipub-track>
<ipub-track-item>
<ipub-track-item-position style="--ipub-track-item-position: 0%;" />
<ipub-soundtrack style="--ipub-color: cyan">
<figure>
<label>
<input type="checkbox" />
<figcaption>Soundtrack 1</figcaption>
</label>
<ipub-audio>
<audio controls="true" volume="0" controlslist="nofullscreen"
disableremoteplayback="true">
<source src="../audios/track1.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
</ipub-track-item>
<ipub-track-item>
<ipub-track-item-position style="--ipub-track-item-position: 50%;" />
<ipub-soundtrack style="--ipub-color: green">
<figure>
<label>
<input type="checkbox" />
<figcaption>Soundtrack 2</figcaption>
</label>
<ipub-audio>
<audio controls="true" volume="0" controlslist="nofullscreen"
disableremoteplayback="true">
<source src="../audios/track2.webm" />
</audio>
</ipub-audio>
</figure>
</ipub-soundtrack>
</ipub-track-item>
</ipub-track>
<ipub-background id="background0001">
<img src="../images/background0001.jpg" width="100" height="100" />
</ipub-background>
<!-- <ipub-soundtrack style="==ipub-color:cyan"> -->
<!-- <!== TODO: Search on how to make this more accessible, more semantic as using <details> -->--&gt;
<!-- <figure> -->
<!-- <label> -->
<!-- <input type="checkbox" /> -->
<!-- <figcaption>Soundtrack 1</figcaption> -->
<!-- </label> -->
<!-- <ipub-audio> -->
<!-- <audio controls="true" volume="0" controlslist="nofullscreen" -->
<!-- disableremoteplayback=""> -->
<!-- <source src="../audios/track1.webm" /> -->
<!-- </audio> -->
<!-- </ipub-audio> -->
<!-- </figure> -->
<!-- </ipub-soundtrack> -->
<ipub-image>
<img src="../images/image0001.png" />
<ipub-interaction style="--ipub-y:88.5%;--ipub-x:6%" circle="">
<a href="https://krita.org" referrerpolicy="same-origin"
rel="external nofollow noopener noreferrer" target="_blank" />
</ipub-interaction>
<ipub-interaction style="--ipub-y:93.5%;--ipub-x:81.5%;--ipub-size:13%;">
<a href="https://guz.one" referrerpolicy="same-origin"
rel="external nofollow noopener noreferrer" target="_blank" />
</ipub-interaction>
</ipub-image>
<ipub-image>
<img src="../images/image0002.png" />
</ipub-image>
<!-- <ipub-soundtrack style="==ipub-color:green;"> -->
<!-- <figure> -->
<!-- <label> -->
<!-- <input type="checkbox" /> -->
<!-- <figcaption>Soundtrack 2</figcaption> -->
<!-- </label> -->
<!-- <ipub-audio> -->
<!-- <audio controls="true" volume="0" controlslist="nofullscreen" disableremoteplayback=""> -->
<!-- <source src="../audios/track2.webm" /> -->
<!-- </audio> -->
<!-- </ipub-audio> -->
<!-- </figure> -->
<!-- </ipub-soundtrack> -->
<ipub-background id="background0002">
<picture>
<img src="../images/background0002.jpg" />
</picture>
</ipub-background>
<ipub-image>
<img src="../images/image0003.png" />
</ipub-image>
<ipub-image>
<img src="../images/image0004.png" />
</ipub-image>
<ipub-background id="background0003">
<picture>
<img src="../images/background0003.jpg" />
</picture>
</ipub-background>
<ipub-image>
<img src="../images/image0002.png" />
</ipub-image>
<!-- <ipub-soundtrack style="==ipub-color:yellow;"> -->
<!-- <figure> -->
<!-- <label> -->
<!-- <input type="checkbox" /> -->
<!-- <figcaption>Soundtrack 3</figcaption> -->
<!-- </label> -->
<!-- <ipub-audio> -->
<!-- <audio controls="true" volume="0" controlslist="nofullscreen" disableremoteplayback=""> -->
<!-- <source src="../audios/track3.webm" /> -->
<!-- </audio> -->
<!-- </ipub-audio> -->
<!-- </figure> -->
<!-- </ipub-soundtrack> -->
<ipub-image>
<img src="../images/image0003.png" />
</ipub-image>
<ipub-image>
<img src="../images/image0004.png" />
</ipub-image></main>
</ipub-body>
</body>
</html>

View File

@@ -28,6 +28,38 @@
outline-style: solid;
}
ipub-track {
z-index: 1000;
position: absolute;
display: inline-block;
top: 0;
left: 0;
width: 100%;
height: 100%;
ipub-track-item {
top: 0;
left: 0;
display: inline-block;
position: absolute;
width: 100%;
height: 100%;
*:not(ipub-offset) {
position: sticky;
top: 0;
left: 0;
}
ipub-track-item-position {
width: 5rem;
display: inline-block;
height: var(--ipub-track-item-position);
}
}
}
ipub-cover > dialog[open] {
--ipub-accent-color: #fff;
z-index: var(--z-cover);
@@ -199,18 +231,6 @@ ipub-soundtrack {
&[playing] figure figcaption::before {
content: "P "; /* TODO: change to an icon and better positioning */
}
& > ipub-trigger {
position: absolute;
display: inline-block;
top: 0;
left: 0;
transform: translateY(--ipub-offset, 0%);
pointer-events: none;
width: var(--ipub-width, 100%);
height: var(--ipub-height, 0%);
}
}
ipub-background {

View File

@@ -0,0 +1,17 @@
# name: TODO tracker
# on: [push, pull_request]
# jobs:
# build:
# runs-on: ubuntu-latest
# steps:
# - uses: https://forge.capytal.company/actions/checkout@v3
# - uses: https://forge.capytal.company/actions/tdg-forgejo-action@master
# with:
# TOKEN: ${{ secrets.FORGEJO_TOKEN }}
# REPO: ${{ github.repository }}
# SHA: ${{ github.sha }}
# REF: ${{ github.ref }}
# LABEL: status/todo
# DRY_RUN: true
# COMMENT_ON_ISSUES: 128
# ASSIGN_FROM_BLAME: 128

1
.gitignore vendored
View File

@@ -3,4 +3,5 @@ out.css
.tmp
.env
*.db
*.epub
tmp

View File

@@ -151,9 +151,9 @@ func (app *app) setup() error {
return fmt.Errorf("app: failed to start token repository: %w", err)
}
publicationRepository, err := repository.NewPublication(app.ctx, app.db, app.logger.WithGroup("repository.publication"), app.assert)
projectRepository, err := repository.NewProject(app.ctx, app.db, app.logger.WithGroup("repository.project"), app.assert)
if err != nil {
return fmt.Errorf("app: failed to start publication repository: %w", err)
return fmt.Errorf("app: failed to start project repository: %w", err)
}
permissionRepository, err := repository.NewPermissions(app.ctx, app.db, app.logger.WithGroup("repository.permission"), app.assert)
@@ -169,12 +169,12 @@ func (app *app) setup() error {
Logger: app.logger.WithGroup("service.token"),
Assertions: app.assert,
})
publicationService := service.NewPublication(publicationRepository, permissionRepository, app.logger.WithGroup("service.publication"), app.assert)
projectService := service.NewProject(projectRepository, permissionRepository, app.logger.WithGroup("service.project"), app.assert)
app.handler, err = router.New(router.Config{
UserService: userService,
TokenService: tokenService,
PublicationService: publicationService,
UserService: userService,
TokenService: tokenService,
ProjectService: projectService,
Templates: app.templates,
DisableCache: app.developmentMode,

View File

@@ -1,13 +0,0 @@
package assets
import (
"embed"
"io/fs"
)
//go:embed css/style.css js/*.js ipub/*.js ipub/*.css
var files embed.FS
func New() fs.FS {
return files
}

View File

@@ -1,225 +0,0 @@
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--color-gray-50: oklch(98.5% 0.002 247.839);
--color-gray-600: oklch(44.6% 0.03 256.802);
--color-gray-700: oklch(37.3% 0.034 259.733);
--color-gray-900: oklch(21% 0.034 264.665);
--spacing: 0.25rem;
--radius-md: 0.375rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden='until-found'])) {
display: none !important;
}
}
@layer utilities {
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.static {
position: static;
}
.contents {
display: contents;
}
.flex {
display: flex;
}
.resize {
resize: both;
}
.flex-col {
flex-direction: column;
}
.bg-gray-900 {
background-color: var(--color-gray-900);
}
.text-gray-50 {
color: var(--color-gray-50);
}
.has-\[\#first-publication\]\:h-full {
&:has(*:is(#first-publication)) {
height: 100%;
}
}
.has-\[\#first-publication\]\:h-svw {
&:has(*:is(#first-publication)) {
height: 100svw;
}
}
}
@layer base {
form {
input {
background-color: var(--color-gray-700);
border-radius: var(--radius-md);
padding-inline-start: calc(var(--spacing) * 2);
&:has(+ button) {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
}
button {
background-color: var(--color-gray-600);
border-radius: var(--radius-md);
padding: 0 calc(var(--spacing) * 2);
input + & {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
}

View File

@@ -1,22 +0,0 @@
@import "tailwindcss";
@layer base {
form {
input {
background-color: var(--color-gray-700);
border-radius: var(--radius-md);
padding-inline-start: --spacing(2);
&:has(+ button) {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
}
button {
background-color: var(--color-gray-600);
border-radius: var(--radius-md);
padding: 0 --spacing(2);
input + & {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
}

View File

@@ -1,128 +0,0 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/capytal/comicverse/editor/assets"
"code.capytal.cc/capytal/comicverse/editor/router"
"code.capytal.cc/capytal/comicverse/editor/storage"
"code.capytal.cc/capytal/comicverse/editor/template"
"code.capytal.cc/loreddev/x/tinyssert"
)
var (
hostname = flag.String("hostname", "localhost", "Host to listen to")
port = flag.Uint("port", 8080, "Port to be used for the server.")
verbose = flag.Bool("verbose", false, "Print debug information on logs")
dev = flag.Bool("dev", false, "Run the server in debug mode.")
)
var (
storageDir = getEnv("EDITOR_PUBLICATIONS_DIR", ".publications") // TODO: Use XDG_STATE_HOME as default
assetsDir = getEnv("EDITOR_ASSETS_DIR", "assets") // TODO: Use XDG_CONFIG_HOME as default
templatesDir = getEnv("EDITOR_TEMPLATES_DIR", "template") // TODO: Use XDG_CONFIG_HOME as default
)
func getEnv(key string, d string) string {
v := os.Getenv(key)
if v == "" {
return d
}
return v
}
func init() {
flag.Parse()
}
func main() {
ctx := context.Background()
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
assert := tinyssert.New(tinyssert.WithLogger(log))
assets := assets.New()
templater, err := template.New()
if err != nil {
log.Error("Unable to initiate templater due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
if *dev {
assets = os.DirFS(assetsDir)
log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
assert = tinyssert.New(tinyssert.WithPanic(), tinyssert.WithLogger(log.WithGroup("assertions")))
templater, err = template.Dev(os.DirFS(templatesDir))
if err != nil {
log.Error("Unable to initiate dev templater due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
}
err = os.MkdirAll(storageDir, os.ModePerm)
if err != nil {
log.Error("Unable to create storage directory due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
root, err := os.OpenRoot(storageDir)
if err != nil {
log.Error("Unable to open storage directory due to error", slog.String("error", err.Error()))
os.Exit(1)
return
}
storage := storage.Newlocal(root, log)
editor := editor.New(storage, log.WithGroup("editor"), assert)
router := router.New(router.Config{
Assets: assets,
Editor: editor,
Templater: templater,
Logger: log.WithGroup("router"),
})
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", *hostname, *port),
Handler: router,
}
c, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Info("Starting application",
slog.String("host", *hostname),
slog.Uint64("port", uint64(*port)),
slog.Bool("verbose", *verbose),
slog.Bool("development", *dev))
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("Failed to start application server", slog.String("error", err.Error()))
os.Exit(1)
}
}()
<-c.Done()
log.Info("Stopping application gracefully")
if err := srv.Shutdown(c); err != nil {
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
os.Exit(1)
}
log.Info("FINAL")
os.Exit(0)
}

View File

@@ -1,69 +0,0 @@
package editor
import (
"bytes"
_ "embed"
"encoding/xml"
"errors"
"fmt"
"log/slog"
"path"
"sync"
"time"
"code.capytal.cc/capytal/comicverse/editor/epub"
"code.capytal.cc/capytal/comicverse/editor/internals/shortid"
"code.capytal.cc/capytal/comicverse/editor/storage"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
"golang.org/x/net/html"
)
type Container struct {
id uuid.UUID
pkg epub.Package
storage storage.Storage
log *slog.Logger
assert tinyssert.Assertions
flushed bool
}
func (p *Container) Flush() error {
p.assert.NotZero(p.pkg, "invalid ePUB: package must be set")
p.assert.NotZero(p.pkg.Metadata, "invalid ePUB: package must have metadata")
p.assert.NotZero(p.pkg.Metadata.ID, "invalid ePUB: ID must always be specified")
p.assert.NotZero(p.pkg.Metadata.Language, "invalid ePUB: Language must always be specified")
p.assert.NotZero(p.pkg.Metadata.Title, "invalid ePUB: Title must always be specified")
p.mu.Lock()
defer p.mu.Unlock()
p.log.Debug("Flushing state of publication")
if p.flushed {
p.log.Debug("Publication doesn't have unsaved changes, skipping flush")
return nil
}
defer p.log.Debug("Publication's state flushed")
b, err := xml.MarshalIndent(p.pkg, "", " ")
if err != nil {
return fmt.Errorf("editor.Publication: failed to marshal package: %w", err)
}
if _, err = p.storage.Write("content.opf", b); err != nil {
return fmt.Errorf("editor.Publication: failed to write content.opf: %w", err)
}
return nil
}

View File

@@ -1,114 +0,0 @@
package editor
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"log/slog"
"sync"
"time"
"code.capytal.cc/capytal/comicverse/editor/epub"
"code.capytal.cc/capytal/comicverse/editor/storage"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
"golang.org/x/text/language"
)
func New(
storage storage.Storage,
logger *slog.Logger,
assert tinyssert.Assertions,
) *Editor {
assert.NotZero(storage)
assert.NotZero(logger)
return &Editor{
storage: storage,
log: logger,
assert: assert,
}
}
type Editor struct {
storage storage.Storage
ctx context.Context
log *slog.Logger
assert tinyssert.Assertions
}
func (e *Editor) New(id uuid.UUID, title string, lang language.Tag) (*Container, error) {
f := fmt.Sprintf("%s/content.opf", id)
if e.storage.Exists(f) {
return nil, ErrAlreadyExists
}
pub := &Container{
id: id,
pkg: epub.Package{
Metadata: epub.Metadata{
ID: fmt.Sprintf("comicverse:%s", id),
Title: title,
Language: lang,
Date: time.Now(),
Modified: time.Now(),
},
},
log: e.log.WithGroup(fmt.Sprintf("publication:%s", id)),
assert: e.assert,
storage: storage.WithRoot(id.String(), e.storage),
}
err := pub.Flush()
if err != nil {
return nil, fmt.Errorf("editor: unable to flush changes of publication: %w", err)
}
return pub, nil
}
func (e *Editor) Open(id uuid.UUID) (*Container, error) {
content, err := e.storage.Open(fmt.Sprintf("%s/content.opf", id))
if errors.Is(err, storage.ErrNotExists) {
return nil, ErrNotExists
} else if err != nil {
return nil, fmt.Errorf("editor: unable to open package: %w", err)
}
b, err := io.ReadAll(content)
if err != nil {
return nil, fmt.Errorf("editor: unable to read contents of package: %w", err)
}
var pkg epub.Package
err = xml.Unmarshal(b, &pkg)
if err != nil {
return nil, fmt.Errorf("editor: unable to decode xml of package: %w", err)
}
c := &Container{
id: id,
pkg: pkg,
log: e.log.WithGroup(fmt.Sprintf("publication:%s", id)),
assert: e.assert,
storage: storage.WithRoot(id.String(), e.storage),
}
return c, nil
}
var (
ErrAlreadyExists = errors.New("editor: file already exists")
ErrNotExists = errors.New("editor: file doesn't exist")
)

View File

@@ -1,45 +0,0 @@
package epub
import (
"encoding/xml"
"fmt"
)
type Meta struct {
Attributes map[string]string `xml:"-"`
Value string `xml:",chardata"`
}
var (
_ xml.Marshaler = Meta{}
_ xml.Unmarshaler = (*Meta)(nil)
)
func (m Meta) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
for n, v := range m.Attributes {
start.Attr = append(start.Attr, xml.Attr{
Name: xml.Name{Local: n},
Value: v,
})
}
return e.EncodeElement(m.Value, start)
}
func (m *Meta) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
if m == nil {
m = &Meta{}
}
if m.Attributes == nil {
m.Attributes = map[string]string{}
}
for _, attr := range start.Attr {
m.Attributes[attr.Name.Local] = attr.Value
}
if err := d.DecodeElement(&m.Value, &start); err != nil {
return fmt.Errorf("epub.Meta: failed to decode chardata: %w", err)
}
return nil
}

View File

@@ -1,450 +0,0 @@
package epub
import (
"encoding/xml"
"fmt"
"strings"
"time"
"code.capytal.cc/capytal/comicverse/editor/internals/shortid"
"golang.org/x/text/language"
)
type Package struct {
Metadata Metadata `xml:"metadata"`
Manifest Manisfest `xml:"manifest"`
Spine Spine `xml:"spine"`
// TODO: Collections https://www.w3.org/TR/epub-33/#sec-pkg-collections
}
var _ xml.Marshaler = Package{}
func (p Package) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
start.Name = xml.Name{
Local: "package",
Space: "http://www.idpf.org/2007/opf",
}
start.Attr = append(start.Attr, []xml.Attr{
{Name: xml.Name{Local: "xmlns:dc"}, Value: "http://purl.org/dc/elements/1.1/"},
{Name: xml.Name{Local: "xmlns:dcterms"}, Value: "http://purl.org/dc/terms/"},
{Name: xml.Name{Local: "xmlns:opf"}, Value: "http://www.idpf.org/2007/opf"},
{Name: xml.Name{Local: "unique-identifier"}, Value: uniqueIdentifierID},
{Name: xml.Name{Local: "version"}, Value: "3.0"},
}...)
if err := e.EncodeToken(start); err != nil {
return err
}
err := e.EncodeElement(p.Metadata, xml.StartElement{Name: xml.Name{Local: "metadata"}})
if err != nil {
return err
}
err = e.EncodeElement(p.Manifest, xml.StartElement{Name: xml.Name{Local: "manifest"}})
if err != nil {
return err
}
err = e.EncodeElement(p.Spine, xml.StartElement{Name: xml.Name{Local: "spine"}})
if err != nil {
return err
}
return e.EncodeToken(xml.EndElement{Name: start.Name})
}
type Metadata struct {
ID string `xml:"dc:identifier"`
Title string `xml:"dc:title"`
Language language.Tag `xml:"dc:language"`
Creators []Person `xml:"dc:creator"`
Contributors []Person `xml:"dc:contributor"`
Date time.Time `xml:"dc:date"`
Modified time.Time `xml:"-"`
// TODO: Support for dc:subject, dc:type and meta elements
// https://www.w3.org/TR/epub-33/#sec-opf-dcsubject
}
var (
_ xml.Marshaler = Metadata{}
_ xml.Unmarshaler = (*Metadata)(nil)
)
func (m Metadata) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if err := e.EncodeToken(start); err != nil {
return err
}
helper := encoderHelper(e, "epub.Metadata")
err := helper("dc:identifier", m.ID, xml.Attr{
Name: xml.Name{Local: "id"}, Value: uniqueIdentifierID,
})
if err != nil {
return err
}
if err = helper("dc:title", m.Title); err != nil {
return err
}
if err = helper("dc:language", m.Language.String()); err != nil {
return err
}
for _, creator := range m.Creators {
err := creator.marshalIntoRootXML(xml.Name{Local: "dc:creator"}, e)
if err != nil {
return err
}
}
for _, contributor := range m.Contributors {
err := contributor.marshalIntoRootXML(xml.Name{Local: "dc:contributor"}, e)
if err != nil {
return err
}
}
if !m.Date.IsZero() {
if err = helper("dc:date", m.Date.Format(time.RFC3339)); err != nil {
return err
}
}
if !m.Modified.IsZero() {
if err = helper("meta", m.Modified.Format(time.RFC3339), xml.Attr{
Name: xml.Name{Local: "property"}, Value: "dcterms:modified",
}); err != nil {
return err
}
}
return e.EncodeToken(start.End())
}
func (m *Metadata) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
if m == nil {
m = &Metadata{}
}
var v struct {
ID string `xml:"http://purl.org/dc/elements/1.1/ identifier"`
Title string `xml:"http://purl.org/dc/elements/1.1/ title"`
Language language.Tag `xml:"http://purl.org/dc/elements/1.1/ language"`
Creators []Person `xml:"http://purl.org/dc/elements/1.1/ creator"`
Contributors []Person `xml:"http://purl.org/dc/elements/1.1/ contributor"`
Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
Meta []Meta `xml:"meta"`
}
if err := d.DecodeElement(&v, &start); err != nil {
return fmt.Errorf("epub.Metadata: unable to unmarshal: %w", err)
}
m.ID = v.ID
m.Title = v.Title
m.Language = v.Language
if v.Date != "" {
t, err := time.Parse(time.RFC3339, v.Date)
if err != nil {
return fmt.Errorf("epub.Metadata: date is not valid: %w", err)
}
m.Date = t
}
m.Creators = v.Creators
for i, c := range m.Creators {
c, err := c.unmarshalFromMetas(v.Meta)
if err != nil {
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
}
m.Creators[i] = c
}
m.Contributors = v.Contributors
for i, c := range m.Contributors {
c, err := c.unmarshalFromMetas(v.Meta)
if err != nil {
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
}
m.Contributors[i] = c
}
for _, meta := range v.Meta {
if property, ok := meta.Attributes["property"]; ok {
switch property {
case "dcterms:modified":
t, err := time.Parse(time.RFC3339, meta.Value)
if err != nil {
return fmt.Errorf("epub.Metadata: modified date is not valid: %w", err)
}
m.Modified = t
}
}
}
return nil
}
var uniqueIdentifierID = "pub-id"
type Person struct {
ID string `xml:"id,attr"`
Name string `xml:",chardata"`
Role string `xml:"-"`
FileAs string `xml:"-"`
AlternateScripts map[language.Tag]string `xml:"-"`
}
func (p Person) marshalIntoRootXML(name xml.Name, e *xml.Encoder) error {
if p.ID == "" {
p.ID = shortid.New().String()
}
err := e.EncodeElement(p.Name, xml.StartElement{
Name: name,
Attr: []xml.Attr{{Name: xml.Name{Local: "id"}, Value: p.ID}},
})
if err != nil {
return err
}
for lang, name := range p.AlternateScripts {
err = e.EncodeElement(name, xml.StartElement{
Name: xml.Name{Local: "meta"},
Attr: []xml.Attr{
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
{Name: xml.Name{Local: "property"}, Value: "alternate-script"},
{Name: xml.Name{Local: "xml:lang"}, Value: lang.String()},
},
})
if err != nil {
return err
}
}
if p.FileAs != "" {
err = e.EncodeElement(p.FileAs, xml.StartElement{
Name: xml.Name{Local: "meta"},
Attr: []xml.Attr{
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
{Name: xml.Name{Local: "property"}, Value: "file-as"},
},
})
if err != nil {
return err
}
}
if p.Role != "" {
err = e.EncodeElement(p.Role, xml.StartElement{
Name: xml.Name{Local: "meta"},
Attr: []xml.Attr{
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
{Name: xml.Name{Local: "property"}, Value: "role"},
{Name: xml.Name{Local: "scheme"}, Value: "marc:relators"},
},
})
if err != nil {
return err
}
}
return nil
}
func (p Person) unmarshalFromMetas(metaList []Meta) (Person, error) {
if p.ID == "" {
return p, nil
}
if p.AlternateScripts == nil {
p.AlternateScripts = map[language.Tag]string{}
}
for _, meta := range metaList {
refines, ok := meta.Attributes["refines"]
if !ok || refines != fmt.Sprintf("#%s", p.ID) {
continue
}
property, ok := meta.Attributes["property"]
if !ok {
continue
}
switch property {
case "alternate-script":
l, ok := meta.Attributes["lang"]
if !ok {
continue
}
lang, err := language.Parse(l)
if err != nil {
return p, fmt.Errorf("epub.Person: language %q is not valid: %w", l, err)
}
p.AlternateScripts[lang] = meta.Value
case "file-as":
p.FileAs = meta.Value
case "role":
p.Role = meta.Value
}
}
return p, nil
}
type Manisfest struct {
Items []Item `xml:"item"`
}
type Item struct {
ID string `xml:"id,attr"`
HRef string `xml:"href,attr"`
MediaType string `xml:"media-type,attr"`
MediaOverlay string `xml:"media-overlay,attr,omitempty"`
Properties ItemProperties `xml:"properties,attr,omitempty"`
}
type Spine struct {
ID string `xml:"id,attr,omitempty"`
Toc string `xml:"toc,attr,omitempty"`
PageProgressionDir PageProgressionDir `xml:"page-progression-direction,attr,omitempty"`
ItemRefs []ItemRef `xml:"itemref"`
}
type PageProgressionDir string
const (
PageProgressionDirDefault PageProgressionDir = "default"
PageProgressionDirLTR PageProgressionDir = "ltr"
PageProgressionDirRTL PageProgressionDir = "rtl"
)
type ItemRef struct {
IDRef string `xml:"idref,attr"`
ID string `xml:"id,attr"`
NotLinear bool `xml:"linear,attr"`
Properties ItemProperties `xml:"properties,attr"`
}
var (
_ xml.Marshaler = ItemRef{}
_ xml.Unmarshaler = (*ItemRef)(nil)
)
func (ref ItemRef) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
linear := xml.Attr{Name: xml.Name{Local: "linear"}}
if !ref.NotLinear {
linear.Value = "no"
} else {
linear.Value = "yes"
}
props, _ := ref.Properties.MarshalXMLAttr(xml.Name{Local: "properties"})
start.Attr = append(start.Attr, []xml.Attr{
{Name: xml.Name{Local: "idref"}, Value: ref.IDRef},
{Name: xml.Name{Local: "id"}, Value: ref.ID},
linear,
props,
}...)
if err := e.EncodeToken(start); err != nil {
return err
}
return e.EncodeToken(start.End())
}
func (ref *ItemRef) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
if ref == nil {
ref = &ItemRef{}
}
for _, attr := range start.Attr {
switch attr.Name.Local {
case "idref":
ref.IDRef = attr.Value
case "id":
ref.ID = attr.Value
case "linear":
if attr.Value == "no" {
ref.NotLinear = true
} else {
ref.NotLinear = false
}
case "properties":
ref.Properties.UnmarshalXMLAttr(attr)
}
}
var t string
return d.DecodeElement(&t, &start)
}
type (
ItemProperty string
ItemProperties []ItemProperty
)
const (
ItemPropertyCoverImage ItemProperty = "cover-image"
ItemPropertyNav ItemProperty = "nav"
ItemPropertyMathML ItemProperty = "mathml"
ItemPropertyRemoteResources ItemProperty = "remote-resources"
ItemPropertyScripted ItemProperty = "scripted"
ItemPropertySVG ItemProperty = "svg"
ItemPropertySwitch ItemProperty = "switch"
)
var (
_ xml.MarshalerAttr = (ItemProperties)(nil)
_ xml.UnmarshalerAttr = (*ItemProperties)(nil)
)
func (is ItemProperties) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
strs := make([]string, len(is))
for i := range is {
strs[i] = string(is[i])
}
return xml.Attr{Name: name, Value: strings.Join(strs, " ")}, nil
}
func (is *ItemProperties) UnmarshalXMLAttr(attr xml.Attr) error {
if is == nil {
is = &ItemProperties{}
}
for s := range strings.SplitSeq(attr.Value, " ") {
*is = append(*is, ItemProperty(s))
}
return nil
}
func encoderHelper(e *xml.Encoder, errPrefix ...string) func(
key string, value string, attrs ...xml.Attr,
) error {
if len(errPrefix) == 0 {
errPrefix[0] = ""
} else {
errPrefix[0] = fmt.Sprintf("%s: ", errPrefix[0])
}
return func(key string, value string, attrs ...xml.Attr) error {
err := e.EncodeElement(value, xml.StartElement{
Name: xml.Name{Local: key},
Attr: attrs,
})
if err != nil {
return fmt.Errorf("%sfailed to encode %q: %w", errPrefix[0], key, err)
}
return nil
}
}

View File

@@ -1,11 +0,0 @@
module code.capytal.cc/capytal/comicverse/editor
go 1.25.2
require (
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1
github.com/google/uuid v1.6.0
golang.org/x/net v0.47.0
golang.org/x/text v0.31.0
)

View File

@@ -1,10 +0,0 @@
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e h1:LdkirHDzhkcnhOBnDN0po84DjHAAkGztjHu/4mfWpSI=
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e/go.mod h1:jMvSPUj295pTk/ixyxZfwZJE/RQ7DZzvQ3cVoAklkPA=
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1 h1:BE0QdvwVVTG/t7nwNO5rrLf1vdAc5axv/1mWd/oAWhw=
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1/go.mod h1:p5ZPHzutdbUDfpvNBCjv5ls6rM4YNl2k4ipD5b0aRho=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=

View File

@@ -1,8 +0,0 @@
Adjectives and names list files were copied from Dustin Kirkland's <dustin.kirkland@gmail.com>
petname project at Github, specifically from these files:
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/adjectives.txt
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/names.txt
The original files are provided and released under the Apache License version 2,
which a copy is available at
https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/LICENSE

View File

@@ -1,449 +0,0 @@
able
above
absolute
accepted
accurate
ace
active
actual
adapted
adapting
adequate
adjusted
advanced
alert
alive
allowed
allowing
amazed
amazing
ample
amused
amusing
apparent
apt
arriving
artistic
assured
assuring
awaited
awake
aware
balanced
becoming
beloved
better
big
blessed
bold
boss
brave
brief
bright
bursting
busy
calm
capable
capital
careful
caring
casual
causal
central
certain
champion
charmed
charming
cheerful
chief
choice
civil
classic
clean
clear
clever
climbing
close
closing
coherent
comic
communal
complete
composed
concise
concrete
content
cool
correct
cosmic
crack
creative
credible
crisp
crucial
cuddly
cunning
curious
current
cute
daring
darling
dashing
dear
decent
deciding
deep
definite
delicate
desired
destined
devoted
direct
discrete
distinct
diverse
divine
dominant
driven
driving
dynamic
eager
easy
electric
elegant
emerging
eminent
enabled
enabling
endless
engaged
engaging
enhanced
enjoyed
enormous
enough
epic
equal
equipped
eternal
ethical
evident
evolved
evolving
exact
excited
exciting
exotic
expert
factual
fair
faithful
famous
fancy
fast
feasible
fine
finer
firm
first
fit
fitting
fleet
flexible
flowing
fluent
flying
fond
frank
free
fresh
full
fun
funky
funny
game
generous
gentle
genuine
giving
glad
glorious
glowing
golden
good
gorgeous
grand
grateful
great
growing
grown
guided
guiding
handy
happy
hardy
harmless
healthy
helped
helpful
helping
heroic
hip
holy
honest
hopeful
hot
huge
humane
humble
humorous
ideal
immense
immortal
immune
improved
in
included
infinite
informed
innocent
inspired
integral
intense
intent
internal
intimate
inviting
joint
just
keen
key
kind
knowing
known
large
lasting
leading
learning
legal
legible
lenient
liberal
light
liked
literate
live
living
logical
loved
loving
loyal
lucky
magical
magnetic
main
major
many
massive
master
mature
maximum
measured
meet
merry
mighty
mint
model
modern
modest
moral
more
moved
moving
musical
mutual
national
native
natural
nearby
neat
needed
neutral
new
next
nice
noble
normal
notable
noted
novel
obliging
on
one
open
optimal
optimum
organic
oriented
outgoing
patient
peaceful
perfect
pet
picked
pleasant
pleased
pleasing
poetic
polished
polite
popular
positive
possible
powerful
precious
precise
premium
prepared
present
pretty
primary
prime
pro
probable
profound
promoted
prompt
proper
proud
proven
pumped
pure
quality
quick
quiet
rapid
rare
rational
ready
real
refined
regular
related
relative
relaxed
relaxing
relevant
relieved
renewed
renewing
resolved
rested
rich
right
robust
romantic
ruling
sacred
safe
saved
saving
secure
select
selected
sensible
set
settled
settling
sharing
sharp
shining
simple
sincere
singular
skilled
smart
smashing
smiling
smooth
social
solid
sought
sound
special
splendid
square
stable
star
steady
sterling
still
stirred
stirring
striking
strong
stunning
subtle
suitable
suited
summary
sunny
super
superb
supreme
sure
sweeping
sweet
talented
teaching
tender
thankful
thorough
tidy
tight
together
tolerant
top
topical
tops
touched
touching
tough
true
trusted
trusting
trusty
ultimate
unbiased
uncommon
unified
unique
united
up
upright
upward
usable
useful
valid
valued
vast
verified
viable
vital
vocal
wanted
warm
wealthy
welcome
welcomed
well
whole
willing
winning
wired
wise
witty
wondrous
workable
working
worthy

View File

@@ -1,452 +0,0 @@
ox
ant
ape
asp
bat
bee
boa
bug
cat
cod
cow
cub
doe
dog
eel
eft
elf
elk
emu
ewe
fly
fox
gar
gnu
hen
hog
imp
jay
kid
kit
koi
lab
man
owl
pig
pug
pup
ram
rat
ray
yak
bass
bear
bird
boar
buck
bull
calf
chow
clam
colt
crab
crow
dane
deer
dodo
dory
dove
drum
duck
fawn
fish
flea
foal
fowl
frog
gnat
goat
grub
gull
hare
hawk
ibex
joey
kite
kiwi
lamb
lark
lion
loon
lynx
mako
mink
mite
mole
moth
mule
mutt
newt
orca
oryx
pika
pony
puma
seal
shad
slug
sole
stag
stud
swan
tahr
teal
tick
toad
tuna
wasp
wolf
worm
wren
yeti
adder
akita
alien
aphid
bison
boxer
bream
bunny
burro
camel
chimp
civet
cobra
coral
corgi
crane
dingo
drake
eagle
egret
filly
finch
gator
gecko
ghost
ghoul
goose
guppy
heron
hippo
horse
hound
husky
hyena
koala
krill
leech
lemur
liger
llama
louse
macaw
midge
molly
moose
moray
mouse
panda
perch
prawn
quail
racer
raven
rhino
robin
satyr
shark
sheep
shrew
skink
skunk
sloth
snail
snake
snipe
squid
stork
swift
tapir
tetra
tiger
troll
trout
viper
wahoo
whale
zebra
alpaca
amoeba
baboon
badger
beagle
bedbug
beetle
bengal
bobcat
caiman
cattle
cicada
collie
condor
cougar
coyote
dassie
dragon
earwig
falcon
feline
ferret
gannet
gibbon
glider
goblin
gopher
grouse
guinea
hermit
hornet
iguana
impala
insect
jackal
jaguar
jennet
kitten
kodiak
lizard
locust
maggot
magpie
mammal
mantis
marlin
marmot
marten
martin
mayfly
minnow
monkey
mullet
muskox
ocelot
oriole
osprey
oyster
parrot
pigeon
piglet
poodle
possum
python
quagga
rabbit
raptor
rodent
roughy
salmon
sawfly
serval
shiner
shrimp
spider
sponge
tarpon
thrush
tomcat
toucan
turkey
turtle
urchin
vervet
walrus
weasel
weevil
wombat
anchovy
anemone
bluejay
buffalo
bulldog
buzzard
caribou
catfish
chamois
cheetah
chicken
chigger
cowbird
crappie
crawdad
cricket
dogfish
dolphin
firefly
garfish
gazelle
gelding
giraffe
gobbler
gorilla
goshawk
grackle
griffon
grizzly
grouper
haddock
hagfish
halibut
hamster
herring
javelin
jawfish
jaybird
katydid
ladybug
lamprey
lemming
leopard
lioness
lobster
macaque
mallard
mammoth
manatee
mastiff
meerkat
mollusk
monarch
mongrel
monitor
monster
mudfish
muskrat
mustang
narwhal
oarfish
octopus
opossum
ostrich
panther
peacock
pegasus
pelican
penguin
phoenix
piranha
polecat
primate
quetzal
raccoon
rattler
redbird
redfish
reptile
rooster
sawfish
sculpin
seagull
skylark
snapper
spaniel
sparrow
sunbeam
sunbird
sunfish
tadpole
terrier
unicorn
vulture
wallaby
walleye
warthog
whippet
wildcat
aardvark
airedale
albacore
anteater
antelope
arachnid
barnacle
basilisk
blowfish
bluebird
bluegill
bonefish
bullfrog
cardinal
chipmunk
cockatoo
crayfish
dinosaur
doberman
duckling
elephant
escargot
flamingo
flounder
foxhound
glowworm
goldfish
grubworm
hedgehog
honeybee
hookworm
humpback
kangaroo
killdeer
kingfish
labrador
lacewing
ladybird
lionfish
longhorn
mackerel
malamute
marmoset
mastodon
moccasin
mongoose
monkfish
mosquito
pangolin
parakeet
pheasant
pipefish
platypus
polliwog
porpoise
reindeer
ringtail
sailfish
scorpion
seahorse
seasnail
sheepdog
shepherd
silkworm
squirrel
stallion
starfish
starling
stingray
stinkbug
sturgeon
terrapin
titmouse
tortoise
treefrog
werewolf
woodcock

View File

@@ -1,33 +0,0 @@
package randname
import (
_ "embed"
"fmt"
"math/rand"
"strings"
)
// TODO: Make generator be based on fantasy, sci-fi and other literature
// and artistic names.
//go:embed adjectives.txt
var adjectives string
//go:embed names.txt
var names string
var (
adjectivesList = strings.Split(adjectives, "\n")
namesList = strings.Split(names, "\n")
)
func New(sep ...string) string {
if len(sep) == 0 {
sep = append(sep, " ")
}
a := adjectivesList[rand.Intn(len(adjectivesList))]
n := namesList[rand.Intn(len(namesList))]
return fmt.Sprintf("%s%s%s", a, sep[0], n)
}

View File

@@ -1,27 +0,0 @@
package router
import (
"net/http"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/capytal/comicverse/editor/internals/randname"
"code.capytal.cc/loreddev/smalltrip/problem"
"code.capytal.cc/loreddev/x/xtemplate"
)
type dashboardController struct {
editor *editor.Editor
templater xtemplate.Templater
}
func (ctrl *dashboardController) dashboard(w http.ResponseWriter, r *http.Request) {
randtitle := randname.New()
err := ctrl.templater.ExecuteTemplate(w, "editor-dashboard", map[string]any{
"RandTitle": randtitle,
})
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
}

View File

@@ -1,68 +0,0 @@
package router
import (
"errors"
"fmt"
"net/http"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/capytal/comicverse/editor/internals/randname"
"code.capytal.cc/loreddev/smalltrip/problem"
"code.capytal.cc/loreddev/x/xtemplate"
"github.com/google/uuid"
"golang.org/x/text/language"
)
type publicationController struct {
editor *editor.Editor
templater xtemplate.Templater
}
func (ctrl *publicationController) createPublication(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title")
if title == "" {
title = randname.New()
}
lang := language.English
id, err := uuid.NewV7()
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
_, err = ctrl.editor.New(id, title, lang)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("./%s", id), http.StatusTemporaryRedirect)
}
func (ctrl *publicationController) getPublication(w http.ResponseWriter, r *http.Request) {
idstr := r.PathValue("publicationID")
if idstr == "" {
problem.NewBadRequest("Missing publication ID in path").ServeHTTP(w, r)
return
}
id, err := uuid.Parse(idstr)
if err != nil {
problem.NewBadRequest("Invalid UUID in path", problem.WithError(err)).ServeHTTP(w, r)
return
}
pkg, err := ctrl.editor.Open(id)
if errors.Is(err, editor.ErrNotExists) {
problem.NewNotFound().ServeHTTP(w, r)
return
} else if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
w.Write(fmt.Appendf([]byte{}, "%+v", pkg))
}

View File

@@ -1,52 +0,0 @@
package router
import (
"io/fs"
"log/slog"
"net/http"
"code.capytal.cc/capytal/comicverse/editor"
"code.capytal.cc/loreddev/smalltrip"
"code.capytal.cc/loreddev/smalltrip/middleware"
"code.capytal.cc/loreddev/smalltrip/multiplexer"
"code.capytal.cc/loreddev/x/xtemplate"
)
func New(cfg Config) http.Handler {
log := cfg.Logger
mux := multiplexer.New()
mux = multiplexer.WithFormMethod(mux, "x-method")
mux = multiplexer.WithPatternRules(mux,
multiplexer.EnsureMethod(),
multiplexer.EnsureTrailingSlash(),
multiplexer.EnsureStrictEnd(),
)
r := smalltrip.NewRouter(
smalltrip.WithMultiplexer(mux),
smalltrip.WithLogger(log.WithGroup("router")),
)
r.Use(middleware.Logger(log.WithGroup("requests")))
// r.Use(problem.Middleware(problem.DefaultHandler))
r.Handle("GET /assets/{asset...}", http.StripPrefix("/assets/", http.FileServerFS(cfg.Assets)))
dashboardCtrl := &dashboardController{editor: cfg.Editor, templater: cfg.Templater}
publicationCtrl := &publicationController{editor: cfg.Editor, templater: cfg.Templater}
r.HandleFunc("GET /{$}", dashboardCtrl.dashboard)
r.HandleFunc("POST /publication/{$}", publicationCtrl.createPublication)
r.HandleFunc("GET /publication/{publicationID}/{$}", publicationCtrl.getPublication)
return r
}
type Config struct {
Assets fs.FS
Editor *editor.Editor
Templater xtemplate.Templater
Logger *slog.Logger
}

View File

@@ -1,98 +0,0 @@
package storage
import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
)
func Newlocal(
root *os.Root,
logger *slog.Logger,
) Storage {
return &local{
log: logger,
root: root,
}
}
type local struct {
log *slog.Logger
root *os.Root
}
var _ Storage = (*local)(nil)
func (files *local) Exists(p string) bool {
if _, err := files.root.Stat(p); err != nil {
return false
}
return true
}
func (files *local) Open(p string) (fs.File, error) {
log := files.log.With(
slog.String("path", p),
slog.String("root", files.root.Name()))
log.Debug("Opening file")
defer log.Debug("File opened")
f, err := files.root.Open(p)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotExists
} else if err != nil {
return nil, err
}
return f, nil
}
func (files *local) Write(p string, d []byte) (int, error) {
log := files.log.With(
slog.String("path", p),
slog.String("root", files.root.Name()))
log.Debug("Writing file")
defer log.Debug("File wrote")
if err := files.root.MkdirAll(path.Dir(p), os.ModePerm); err != nil {
return 0, fmt.Errorf("file.local: failed to create parent directories %q: %w", path.Dir(p), err)
}
err := files.root.WriteFile(p, d, os.ModePerm)
if err != nil {
return 0, fmt.Errorf("file.local: failed to write file %q: %w", p, err)
}
return len(d), nil
}
func (files *local) WriteFrom(p string, r io.Reader) (int64, error) {
log := files.log.With(
slog.String("path", p),
slog.String("root", files.root.Name()))
log.Debug("Writing file")
defer log.Debug("File wrote")
if err := files.root.MkdirAll(path.Dir(p), os.ModePerm); err != nil {
return 0, fmt.Errorf("file.local: failed to create parent directories %q: %w", path.Dir(p), err)
}
f, err := files.root.Create(p)
if err != nil {
return 0, fmt.Errorf("file.local: failed to create file %q: %w", p, err)
}
n, err := f.ReadFrom(r)
if err != nil {
return 0, fmt.Errorf("file.local: failed to write file %q: %w", p, err)
}
return n, nil
}

View File

@@ -1,42 +0,0 @@
package storage
import (
"io"
"io/fs"
"os"
"path"
)
type Storage interface {
Exists(p string) bool
Open(p string) (fs.File, error)
Write(p string, b []byte) (int, error)
WriteFrom(p string, r io.Reader) (int64, error)
}
type withRoot struct {
root string
Storage
}
func WithRoot(rootDir string, s Storage) Storage {
return &withRoot{root: rootDir, Storage: s}
}
func (f *withRoot) Exists(p string) bool {
return f.Storage.Exists(path.Join(f.root, p))
}
func (f *withRoot) Open(p string) (fs.File, error) {
return f.Storage.Open(path.Join(f.root, p))
}
func (f *withRoot) Write(p string, b []byte) (int, error) {
return f.Storage.Write(path.Join(f.root, p), b)
}
func (f *withRoot) WriteFrom(p string, r io.Reader) (int64, error) {
return f.Storage.WriteFrom(path.Join(f.root, p), r)
}
var ErrNotExists = os.ErrNotExist

View File

@@ -1,31 +0,0 @@
{{define "editor-dashboard"}} {{template "layout-base"}}
<body class="bg-gray-900 text-gray-50 has-[#first-publication]:h-svw">
<main class="has-[#first-publication]:h-full flex flex-col">
{{if .Publications}}
<p>Publications</p>
{{else}}
<h1>Create your first publication</h1>
<form method="post" action="/publication/" id="first-publication">
<input
id="title"
type="text"
name="title"
value="{{if .RandTitle}}{{.RandTitle}}{{end}}"
/><button type="submit">Create</button>
</form>
<style>
body:has(:is(#first-publication)) {
height: 100svh;
& > main {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
}
</style>
{{end}}
</main>
</body>
{{template "layout-base-end"}} {{end}}

View File

@@ -1,19 +0,0 @@
{{define "layout-base"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{if .Title}}
<title>{{.Title}}</title>
{{end}}
<link href="/assets/css/style.css" rel="stylesheet" />
<script
src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.js"
integrity="sha384-oeUn82QNXPuVkGCkcrInrS1twIxKhkZiFfr2TdiuObZ3n3yIeMiqcRzkIcguaof1"
crossorigin="anonymous"
></script>
</head>
{{end}} {{define "layout-base-end"}}
</html>
{{end}}

View File

@@ -1,50 +0,0 @@
package template
import (
"embed"
"errors"
"fmt"
"html/template"
"io/fs"
"code.capytal.cc/loreddev/x/xtemplate"
)
func New() (xtemplate.Template, error) {
return xtemplate.New[template.Template]("template").
Funcs(functions).
ParseFS(embedded, patterns...)
}
//go:embed *.html layouts/*.html
var embedded embed.FS
func Dev(dir fs.FS) (xtemplate.Template, error) {
return xtemplate.NewHot[template.Template]("template").
Funcs(functions).
ParseFS(dir, patterns...)
}
var (
patterns = []string{"*.html", "layouts/*.html"}
functions = template.FuncMap{
"args": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, errors.New("misaligned map in template arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("cannot use type %T as map key", pairs[i])
}
m[key] = pairs[i+1]
}
return m, nil
},
}
)

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1762844143,
"narHash": "sha256-SlybxLZ1/e4T2lb1czEtWVzDCVSTvk9WLwGhmxFmBxI=",
"lastModified": 1742069588,
"narHash": "sha256-C7jVfohcGzdZRF6DO+ybyG/sqpo1h6bZi9T56sxLy+k=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9da7f1cf7f8a6e2a7cb3001b048546c92a8258b4",
"rev": "c80f6a7e10b39afcc1894e02ef785b1ad0b0d7e5",
"type": "github"
},
"original": {

View File

@@ -31,7 +31,7 @@
buildInputs = with pkgs; [
# Go tools
go_1_25
go
golangci-lint
gofumpt
gotools

5
go.mod
View File

@@ -1,6 +1,6 @@
module code.capytal.cc/capytal/comicverse
go 1.25.2
go 1.24.8
require (
code.capytal.cc/loreddev/smalltrip v0.0.0-20251013182121-3d201d21226c
@@ -10,7 +10,7 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92
golang.org/x/crypto v0.43.0
golang.org/x/crypto v0.38.0
)
require (
@@ -26,5 +26,4 @@ require (
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
golang.org/x/sync v0.17.0 // indirect
)

6
go.sum
View File

@@ -38,9 +38,11 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92 h1:IYI1S1xt4WdQHjgVYzMa+Owot82BqlZfQV05BLnTcTA=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

View File

@@ -1,8 +1,7 @@
go 1.25.2
go 1.24.8
use (
.
./editor
./.
./smalltrip
./x
)

View File

@@ -1,16 +1,47 @@
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1 h1:1M0gSbyP6q06gl3384wpoKPaH9G16NPqZFieEhLboSU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.1/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92 h1:IYI1S1xt4WdQHjgVYzMa+Owot82BqlZfQV05BLnTcTA=
github.com/tursodatabase/go-libsql v0.0.0-20241221181756-6121e81fbf92/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

View File

@@ -1,8 +0,0 @@
Adjectives and names list files were copied from Dustin Kirkland's <dustin.kirkland@gmail.com>
petname project at Github, specifically from these files:
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/adjectives.txt
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/names.txt
The original files are provided and released under the Apache License version 2,
which a copy is available at
https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/LICENSE

View File

@@ -1,452 +0,0 @@
ox
ant
ape
asp
bat
bee
boa
bug
cat
cod
cow
cub
doe
dog
eel
eft
elf
elk
emu
ewe
fly
fox
gar
gnu
hen
hog
imp
jay
kid
kit
koi
lab
man
owl
pig
pug
pup
ram
rat
ray
yak
bass
bear
bird
boar
buck
bull
calf
chow
clam
colt
crab
crow
dane
deer
dodo
dory
dove
drum
duck
fawn
fish
flea
foal
fowl
frog
gnat
goat
grub
gull
hare
hawk
ibex
joey
kite
kiwi
lamb
lark
lion
loon
lynx
mako
mink
mite
mole
moth
mule
mutt
newt
orca
oryx
pika
pony
puma
seal
shad
slug
sole
stag
stud
swan
tahr
teal
tick
toad
tuna
wasp
wolf
worm
wren
yeti
adder
akita
alien
aphid
bison
boxer
bream
bunny
burro
camel
chimp
civet
cobra
coral
corgi
crane
dingo
drake
eagle
egret
filly
finch
gator
gecko
ghost
ghoul
goose
guppy
heron
hippo
horse
hound
husky
hyena
koala
krill
leech
lemur
liger
llama
louse
macaw
midge
molly
moose
moray
mouse
panda
perch
prawn
quail
racer
raven
rhino
robin
satyr
shark
sheep
shrew
skink
skunk
sloth
snail
snake
snipe
squid
stork
swift
tapir
tetra
tiger
troll
trout
viper
wahoo
whale
zebra
alpaca
amoeba
baboon
badger
beagle
bedbug
beetle
bengal
bobcat
caiman
cattle
cicada
collie
condor
cougar
coyote
dassie
dragon
earwig
falcon
feline
ferret
gannet
gibbon
glider
goblin
gopher
grouse
guinea
hermit
hornet
iguana
impala
insect
jackal
jaguar
jennet
kitten
kodiak
lizard
locust
maggot
magpie
mammal
mantis
marlin
marmot
marten
martin
mayfly
minnow
monkey
mullet
muskox
ocelot
oriole
osprey
oyster
parrot
pigeon
piglet
poodle
possum
python
quagga
rabbit
raptor
rodent
roughy
salmon
sawfly
serval
shiner
shrimp
spider
sponge
tarpon
thrush
tomcat
toucan
turkey
turtle
urchin
vervet
walrus
weasel
weevil
wombat
anchovy
anemone
bluejay
buffalo
bulldog
buzzard
caribou
catfish
chamois
cheetah
chicken
chigger
cowbird
crappie
crawdad
cricket
dogfish
dolphin
firefly
garfish
gazelle
gelding
giraffe
gobbler
gorilla
goshawk
grackle
griffon
grizzly
grouper
haddock
hagfish
halibut
hamster
herring
javelin
jawfish
jaybird
katydid
ladybug
lamprey
lemming
leopard
lioness
lobster
macaque
mallard
mammoth
manatee
mastiff
meerkat
mollusk
monarch
mongrel
monitor
monster
mudfish
muskrat
mustang
narwhal
oarfish
octopus
opossum
ostrich
panther
peacock
pegasus
pelican
penguin
phoenix
piranha
polecat
primate
quetzal
raccoon
rattler
redbird
redfish
reptile
rooster
sawfish
sculpin
seagull
skylark
snapper
spaniel
sparrow
sunbeam
sunbird
sunfish
tadpole
terrier
unicorn
vulture
wallaby
walleye
warthog
whippet
wildcat
aardvark
airedale
albacore
anteater
antelope
arachnid
barnacle
basilisk
blowfish
bluebird
bluegill
bonefish
bullfrog
cardinal
chipmunk
cockatoo
crayfish
dinosaur
doberman
duckling
elephant
escargot
flamingo
flounder
foxhound
glowworm
goldfish
grubworm
hedgehog
honeybee
hookworm
humpback
kangaroo
killdeer
kingfish
labrador
lacewing
ladybird
lionfish
longhorn
mackerel
malamute
marmoset
mastodon
moccasin
mongoose
monkfish
mosquito
pangolin
parakeet
pheasant
pipefish
platypus
polliwog
porpoise
reindeer
ringtail
sailfish
scorpion
seahorse
seasnail
sheepdog
shepherd
silkworm
squirrel
stallion
starfish
starling
stingray
stinkbug
sturgeon
terrapin
titmouse
tortoise
treefrog
werewolf
woodcock

View File

@@ -1,449 +0,0 @@
able
above
absolute
accepted
accurate
ace
active
actual
adapted
adapting
adequate
adjusted
advanced
alert
alive
allowed
allowing
amazed
amazing
ample
amused
amusing
apparent
apt
arriving
artistic
assured
assuring
awaited
awake
aware
balanced
becoming
beloved
better
big
blessed
bold
boss
brave
brief
bright
bursting
busy
calm
capable
capital
careful
caring
casual
causal
central
certain
champion
charmed
charming
cheerful
chief
choice
civil
classic
clean
clear
clever
climbing
close
closing
coherent
comic
communal
complete
composed
concise
concrete
content
cool
correct
cosmic
crack
creative
credible
crisp
crucial
cuddly
cunning
curious
current
cute
daring
darling
dashing
dear
decent
deciding
deep
definite
delicate
desired
destined
devoted
direct
discrete
distinct
diverse
divine
dominant
driven
driving
dynamic
eager
easy
electric
elegant
emerging
eminent
enabled
enabling
endless
engaged
engaging
enhanced
enjoyed
enormous
enough
epic
equal
equipped
eternal
ethical
evident
evolved
evolving
exact
excited
exciting
exotic
expert
factual
fair
faithful
famous
fancy
fast
feasible
fine
finer
firm
first
fit
fitting
fleet
flexible
flowing
fluent
flying
fond
frank
free
fresh
full
fun
funky
funny
game
generous
gentle
genuine
giving
glad
glorious
glowing
golden
good
gorgeous
grand
grateful
great
growing
grown
guided
guiding
handy
happy
hardy
harmless
healthy
helped
helpful
helping
heroic
hip
holy
honest
hopeful
hot
huge
humane
humble
humorous
ideal
immense
immortal
immune
improved
in
included
infinite
informed
innocent
inspired
integral
intense
intent
internal
intimate
inviting
joint
just
keen
key
kind
knowing
known
large
lasting
leading
learning
legal
legible
lenient
liberal
light
liked
literate
live
living
logical
loved
loving
loyal
lucky
magical
magnetic
main
major
many
massive
master
mature
maximum
measured
meet
merry
mighty
mint
model
modern
modest
moral
more
moved
moving
musical
mutual
national
native
natural
nearby
neat
needed
neutral
new
next
nice
noble
normal
notable
noted
novel
obliging
on
one
open
optimal
optimum
organic
oriented
outgoing
patient
peaceful
perfect
pet
picked
pleasant
pleased
pleasing
poetic
polished
polite
popular
positive
possible
powerful
precious
precise
premium
prepared
present
pretty
primary
prime
pro
probable
profound
promoted
prompt
proper
proud
proven
pumped
pure
quality
quick
quiet
rapid
rare
rational
ready
real
refined
regular
related
relative
relaxed
relaxing
relevant
relieved
renewed
renewing
resolved
rested
rich
right
robust
romantic
ruling
sacred
safe
saved
saving
secure
select
selected
sensible
set
settled
settling
sharing
sharp
shining
simple
sincere
singular
skilled
smart
smashing
smiling
smooth
social
solid
sought
sound
special
splendid
square
stable
star
steady
sterling
still
stirred
stirring
striking
strong
stunning
subtle
suitable
suited
summary
sunny
super
superb
supreme
sure
sweeping
sweet
talented
teaching
tender
thankful
thorough
tidy
tight
together
tolerant
top
topical
tops
touched
touching
tough
true
trusted
trusting
trusty
ultimate
unbiased
uncommon
unified
unique
united
up
upright
upward
usable
useful
valid
valued
vast
verified
viable
vital
vocal
wanted
warm
wealthy
welcome
welcomed
well
whole
willing
winning
wired
wise
witty
wondrous
workable
working
worthy

View File

@@ -1,33 +0,0 @@
package randname
import (
_ "embed"
"fmt"
"math/rand"
"strings"
)
// TODO: Make generator be based on fantasy, sci-fi and other literature
// and artistic names.
//go:embed adjectives.txt
var adjectives string
//go:embed names.txt
var names string
var (
adjectivesList = strings.Split(adjectives, "\n")
namesList = strings.Split(names, "\n")
)
func New(sep ...string) string {
if len(sep) == 0 {
sep = append(sep, " ")
}
a := adjectivesList[rand.Intn(len(adjectivesList))]
n := namesList[rand.Intn(len(namesList))]
return fmt.Sprintf("%s%s%s", a, sep[0], n)
}

View File

@@ -30,26 +30,6 @@ dev:
dev/debug:
$(MAKE) -j2 debug dev/assets
editor/dev/server:
cd ./editor; 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
editor/dev/assets:
cd ./editor; tailwindcss \
-i ./assets/css/tailwind.css \
-o ./assets/css/style.css \
--watch
editor/dev:
$(MAKE) -j2 editor/dev/assets editor/dev/server
debug:
dlv debug -l 127.0.0.1:38697 \
--continue \

View File

@@ -120,7 +120,7 @@ const (
PermissionAuthor Permissions = 0x1111111111111111 // "author"
PermissionAdminDelete Permissions = 0x1000000000000000 // "admin.delete" -----
PermissionAdminAll Permissions = 0x0111110000000001 // "admin.all"
PermissionAdminPublication Permissions = 0x0100000000000000 // "admin.publication"
PermissionAdminProject Permissions = 0x0100000000000000 // "admin.project"
PermissionAdminMembers Permissions = 0x0010000000000000 // "admin.members"
PermissionEditAll Permissions = 0x0000001111111111 // "edit.all" ---------
PermissionEditPages Permissions = 0x0000000100000000 // "edit.pages"
@@ -134,7 +134,7 @@ const (
var PermissionLabels = map[Permissions]string{
PermissionAuthor: "author",
PermissionAdminDelete: "admin.delete",
PermissionAdminPublication: "admin.publication",
PermissionAdminProject: "admin.project",
PermissionAdminMembers: "admin.members",
PermissionEditPages: "edit.pages",
PermissionEditInteractions: "edit.interactions",

View File

@@ -6,16 +6,16 @@ import (
"github.com/google/uuid"
)
type Publication struct {
type Project struct {
ID uuid.UUID // Must be unique, represented as base64 string in URLs
Title string // Must not be empty
DateCreated time.Time
DateUpdated time.Time
}
var _ Model = (*Publication)(nil)
var _ Model = (*Project)(nil)
func (p Publication) Validate() error {
func (p Project) Validate() error {
errs := []error{}
if len(p.ID) == 0 {
errs = append(errs, ErrZeroValue{Name: "UUID"})
@@ -31,7 +31,7 @@ func (p Publication) Validate() error {
}
if len(errs) > 0 {
return ErrInvalidModel{Name: "Publication", Errors: errs}
return ErrInvalidModel{Name: "Project", Errors: errs}
}
return nil

View File

@@ -17,7 +17,7 @@ type Permissions struct {
baseRepostiory
}
// Must be initiated after [User] and [Publication]
// Must be initiated after [User] and [Project]
func NewPermissions(
ctx context.Context,
db *sql.DB,
@@ -32,17 +32,17 @@ func NewPermissions(
}
q := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS publication_permissions (
publication_id TEXT NOT NULL,
CREATE TABLE IF NOT EXISTS project_permissions (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permissions_value INTEGER NOT NULL DEFAULT '0',
_permissions_text TEXT NOT NULL DEFAULT '', -- For display purposes only, may not always be up-to-date
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(publication_id, user_id)
FOREIGN KEY(publication_id)
REFERENCES publications (id)
PRIMARY KEY(project_id, user_id)
FOREIGN KEY(project_id)
REFERENCES projects (id)
ON DELETE CASCADE
ON UPDATE RESTRICT,
FOREIGN KEY(user_id)
@@ -58,13 +58,13 @@ func NewPermissions(
}
if err := tx.Commit(); err != nil {
return nil, errors.Join(errors.New("unable to create publication tables"), err)
return nil, errors.Join(errors.New("unable to create project tables"), err)
}
return &Permissions{baseRepostiory: b}, nil
}
func (repo Permissions) Create(publication, user uuid.UUID, permissions model.Permissions) error {
func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permissions) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -75,21 +75,21 @@ func (repo Permissions) Create(publication, user uuid.UUID, permissions model.Pe
}
q := `
INSERT INTO publication_permissions (publication_id, user_id, permissions_value, _permissions_text, created_at, updated_at)
VALUES (:publication_id, :user_id, :permissions_value, :permissions_text, :created_at, :updated_at)
INSERT INTO project_permissions (project_id, user_id, permissions_value, _permissions_text, created_at, updated_at)
VALUES (:project_id, :user_id, :permissions_value, :permissions_text, :created_at, :updated_at)
`
now := time.Now()
log := repo.log.With(slog.String("publication_id", publication.String()),
log := repo.log.With(slog.String("project_id", project.String()),
slog.String("user_id", user.String()),
slog.String("permissions", fmt.Sprintf("%d", permissions)),
slog.String("permissions_text", permissions.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Inserting new publication permissions")
log.DebugContext(repo.ctx, "Inserting new project permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("publication_id", publication),
sql.Named("project_id", project),
sql.Named("user_id", user),
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
@@ -97,7 +97,7 @@ func (repo Permissions) Create(publication, user uuid.UUID, permissions model.Pe
sql.Named("updated_at", now.Format(dateFormat)),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert publication permissions", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to insert project permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -109,24 +109,24 @@ func (repo Permissions) Create(publication, user uuid.UUID, permissions model.Pe
return nil
}
func (repo Permissions) GetByID(publication uuid.UUID, user uuid.UUID) (model.Permissions, error) {
func (repo Permissions) GetByID(project uuid.UUID, user uuid.UUID) (model.Permissions, error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
q := `
SELECT permissions_value FROM publication_permissions
WHERE publication_id = :publication_id
SELECT permissions_value FROM project_permissions
WHERE project_id = :project_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("projcet_id", publication.String()),
log := repo.log.With(slog.String("projcet_id", project.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Getting by ID")
row := repo.db.QueryRowContext(repo.ctx, q,
sql.Named("publication_id", user),
sql.Named("project_id", user),
sql.Named("user_id", user))
var p model.Permissions
@@ -138,7 +138,7 @@ func (repo Permissions) GetByID(publication uuid.UUID, user uuid.UUID) (model.Pe
return p, nil
}
// GetByUserID returns a publication_id-to-permissions map containing all publications and permissions that said userID
// GetByUserID returns a project_id-to-permissions map containing all projects and permissions that said userID
// has relation to.
func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]model.Permissions, err error) {
repo.assert.NotNil(repo.db)
@@ -152,7 +152,7 @@ func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]m
}
q := `
SELECT publication_id, permissions_value FROM publication_permissions
SELECT project_id, permissions_value FROM project_permissions
WHERE user_id = :user_id
`
@@ -176,16 +176,16 @@ func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]m
ps := map[uuid.UUID]model.Permissions{}
for rows.Next() {
var publication uuid.UUID
var project uuid.UUID
var permissions model.Permissions
err := rows.Scan(&publication, &permissions)
err := rows.Scan(&project, &permissions)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan permissions of user id", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
ps[publication] = permissions
ps[project] = permissions
}
if err := tx.Commit(); err != nil {
@@ -196,7 +196,7 @@ func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]m
return ps, nil
}
func (repo Permissions) Update(publication, user uuid.UUID, permissions model.Permissions) error {
func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permissions) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
@@ -207,20 +207,20 @@ func (repo Permissions) Update(publication, user uuid.UUID, permissions model.Pe
}
q := `
UPDATE publication_permissions
UPDATE project_permissions
SET permissions_value = :permissions_value
_permissions_text = :permissions_text
updated_at = :updated_at
WHERE publication_uuid = :publication_uuid
WHERE project_uuid = :project_uuid
AND user_uuid = :user_uuid
`
log := repo.log.With(slog.String("publication_id", publication.String()),
log := repo.log.With(slog.String("project_id", project.String()),
slog.String("user_id", user.String()),
slog.String("permissions", fmt.Sprintf("%d", permissions)),
slog.String("permissions_text", permissions.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Updating publication permissions")
log.DebugContext(repo.ctx, "Updating project permissions")
now := time.Now()
@@ -228,11 +228,11 @@ func (repo Permissions) Update(publication, user uuid.UUID, permissions model.Pe
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
sql.Named("updated_at", now.Format(dateFormat)),
sql.Named("publication_id", publication),
sql.Named("project_id", project),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to update publication permissions", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to update project permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -244,7 +244,7 @@ func (repo Permissions) Update(publication, user uuid.UUID, permissions model.Pe
return nil
}
func (repo Permissions) Delete(publication, user uuid.UUID) error {
func (repo Permissions) Delete(project, user uuid.UUID) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -255,22 +255,22 @@ func (repo Permissions) Delete(publication, user uuid.UUID) error {
}
q := `
DELETE FROM publication_permissions
WHERE publication_id = :publication_id
DELETE FROM project_permissions
WHERE project_id = :project_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("publication_id", publication.String()),
log := repo.log.With(slog.String("project_id", project.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting publication permissions")
log.DebugContext(repo.ctx, "Deleting project permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("publication_id", publication),
sql.Named("project_id", project),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete publication permissions", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to delete project permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}

View File

@@ -14,11 +14,11 @@ import (
"github.com/google/uuid"
)
type Publication struct {
type Project struct {
baseRepostiory
}
func NewPublication(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Publication, error) {
func NewProject(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Project, error) {
b := newBaseRepostiory(ctx, db, log, assert)
tx, err := db.BeginTx(ctx, nil)
@@ -27,7 +27,7 @@ func NewPublication(ctx context.Context, db *sql.DB, log *slog.Logger, assert ti
}
_, err = tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS publications (
CREATE TABLE IF NOT EXISTS projects (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
@@ -38,13 +38,13 @@ func NewPublication(ctx context.Context, db *sql.DB, log *slog.Logger, assert ti
}
if err := tx.Commit(); err != nil {
return nil, errors.Join(errors.New("unable to create publication tables"), err)
return nil, errors.Join(errors.New("unable to create project tables"), err)
}
return &Publication{baseRepostiory: b}, nil
return &Project{baseRepostiory: b}, nil
}
func (repo Publication) Create(p model.Publication) error {
func (repo Project) Create(p model.Project) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -59,12 +59,12 @@ func (repo Publication) Create(p model.Publication) error {
}
q := `
INSERT INTO publications (id, title, created_at, updated_at)
INSERT INTO projects (id, title, created_at, updated_at)
VALUES (:id, :title, :created_at, :updated_at)
`
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Inserting new publication")
log.DebugContext(repo.ctx, "Inserting new project")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("id", p.ID),
@@ -73,7 +73,7 @@ func (repo Publication) Create(p model.Publication) error {
sql.Named("updated_at", p.DateUpdated.Format(dateFormat)),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert publication", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to insert project", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -85,20 +85,20 @@ func (repo Publication) Create(p model.Publication) error {
return nil
}
func (repo Publication) GetByID(publicationID uuid.UUID) (publication model.Publication, err error) {
func (repo Project) GetByID(projectID uuid.UUID) (project model.Project, err error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
q := `
SELECT id, title, created_at, updated_at FROM publications
SELECT id, title, created_at, updated_at FROM projects
WHERE id = :id
`
log := repo.log.With(slog.String("query", q), slog.String("id", publicationID.String()))
log.DebugContext(repo.ctx, "Getting publication by ID")
log := repo.log.With(slog.String("query", q), slog.String("id", projectID.String()))
log.DebugContext(repo.ctx, "Getting project by ID")
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("id", publicationID))
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("id", projectID))
var id uuid.UUID
var title string
@@ -106,23 +106,23 @@ func (repo Publication) GetByID(publicationID uuid.UUID) (publication model.Publ
err = row.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return model.Project{}, errors.Join(ErrInvalidOutput, err)
}
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return model.Project{}, errors.Join(ErrInvalidOutput, err)
}
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return model.Project{}, errors.Join(ErrInvalidOutput, err)
}
return model.Publication{
return model.Project{
ID: id,
Title: title,
DateCreated: dateCreated,
@@ -130,7 +130,7 @@ func (repo Publication) GetByID(publicationID uuid.UUID) (publication model.Publ
}, nil
}
func (repo Publication) GetByIDs(ids []uuid.UUID) (publications []model.Publication, err error) {
func (repo Project) GetByIDs(ids []uuid.UUID) (projects []model.Project, err error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
@@ -147,16 +147,16 @@ func (repo Publication) GetByIDs(ids []uuid.UUID) (publications []model.Publicat
}
q := fmt.Sprintf(`
SELECT id, title, created_at, updated_at FROM publications
SELECT id, title, created_at, updated_at FROM projects
WHERE %s
`, strings.Join(c, " OR "))
log := repo.log.With(slog.String("query", q))
log.DebugContext(repo.ctx, "Getting publications by IDs")
log.DebugContext(repo.ctx, "Getting projects by IDs")
rows, err := tx.QueryContext(repo.ctx, q)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to get publications by IDs", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to get projects by IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrExecuteQuery, err)
}
@@ -167,7 +167,7 @@ func (repo Publication) GetByIDs(ids []uuid.UUID) (publications []model.Publicat
}
}()
ps := []model.Publication{}
ps := []model.Project{}
for rows.Next() {
var id uuid.UUID
@@ -176,23 +176,23 @@ func (repo Publication) GetByIDs(ids []uuid.UUID) (publications []model.Publicat
err := rows.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
ps = append(ps, model.Publication{
ps = append(ps, model.Project{
ID: id,
Title: title,
DateCreated: dateCreated,
@@ -208,7 +208,7 @@ func (repo Publication) GetByIDs(ids []uuid.UUID) (publications []model.Publicat
return ps, nil
}
func (repo Publication) Update(p model.Publication) error {
func (repo Project) Update(p model.Project) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -223,14 +223,14 @@ func (repo Publication) Update(p model.Publication) error {
}
q := `
UPDATE publications
UPDATE projects
SET title = :title
updated_at = :updated_at
WHERE id = :id
`
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Updating publication")
log.DebugContext(repo.ctx, "Updating project")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("title", p.Title),
@@ -238,7 +238,7 @@ func (repo Publication) Update(p model.Publication) error {
sql.Named("id", p.ID),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert publication", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to insert project", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -250,7 +250,7 @@ func (repo Publication) Update(p model.Publication) error {
return nil
}
func (repo Publication) DeleteByID(id uuid.UUID) error {
func (repo Project) DeleteByID(id uuid.UUID) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -261,15 +261,15 @@ func (repo Publication) DeleteByID(id uuid.UUID) error {
}
q := `
DELETE FROM publications WHERE id = :id
DELETE FROM projects WHERE id = :id
`
log := repo.log.With(slog.String("id", id.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting publication")
log.DebugContext(repo.ctx, "Deleting project")
_, err = tx.ExecContext(repo.ctx, q, sql.Named("id", id))
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete publication", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to delete project", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}

View File

@@ -35,7 +35,6 @@ func newBaseRepostiory(ctx context.Context, db *sql.DB, log *slog.Logger, assert
var (
// TODO: Change all ErrDatabaseConn to ErrCloseConn
// TODO: Change error to be agnostic to underlying storage type
ErrDatabaseConn = errors.New("repository: failed to begin transaction/connection with database")
ErrCloseConn = errors.New("repository: failed to close/commit connection")
ErrExecuteQuery = errors.New("repository: failed to execute query")

View File

@@ -1,17 +0,0 @@
package repository
import (
"context"
"database/sql"
"log/slog"
"code.capytal.cc/loreddev/x/tinyssert"
)
type RoleRepository struct {
db *sql.DB
ctx context.Context
log *slog.Logger
assert tinyssert.Assertions
}

View File

@@ -14,27 +14,27 @@ import (
"github.com/google/uuid"
)
type publicationController struct {
publicationSvc *service.Publication
type projectController struct {
projectSvc *service.Project
templates templates.ITemplate
assert tinyssert.Assertions
}
func newPublicationController(
publicationService *service.Publication,
func newProjectController(
projectService *service.Project,
templates templates.ITemplate,
assertions tinyssert.Assertions,
) *publicationController {
return &publicationController{
publicationSvc: publicationService,
templates: templates,
assert: assertions,
) *projectController {
return &projectController{
projectSvc: projectService,
templates: templates,
assert: assertions,
}
}
func (ctrl publicationController) dashboard(w http.ResponseWriter, r *http.Request) {
func (ctrl projectController) dashboard(w http.ResponseWriter, r *http.Request) {
userCtx := NewUserContext(r.Context())
userID, ok := userCtx.GetUserID()
@@ -43,7 +43,7 @@ func (ctrl publicationController) dashboard(w http.ResponseWriter, r *http.Reque
return
}
publications, err := ctrl.publicationSvc.ListOwnedBy(userID)
projects, err := ctrl.projectSvc.GetUserProjects(userID)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
@@ -52,15 +52,15 @@ func (ctrl publicationController) dashboard(w http.ResponseWriter, r *http.Reque
ps := make([]struct {
ID string
Title string
}, len(publications))
}, len(projects))
for i, publication := range publications {
for i, project := range projects {
ps[i] = struct {
ID string
Title string
}{
ID: base64.URLEncoding.EncodeToString([]byte(publication.ID.String())),
Title: publication.Title,
ID: base64.URLEncoding.EncodeToString([]byte(project.ID.String())),
Title: project.Title,
}
}
@@ -70,24 +70,24 @@ func (ctrl publicationController) dashboard(w http.ResponseWriter, r *http.Reque
}
}
func (ctrl publicationController) getPublication(w http.ResponseWriter, r *http.Request) {
// TODO: Handle private publications
func (ctrl projectController) getProject(w http.ResponseWriter, r *http.Request) {
// TODO: Handle private projects
shortPublicationID := r.PathValue("publicationID")
shortProjectID := r.PathValue("projectID")
id, err := base64.URLEncoding.DecodeString(shortPublicationID)
id, err := base64.URLEncoding.DecodeString(shortProjectID)
if err != nil {
problem.NewBadRequest(fmt.Sprintf("Incorrectly encoded publication ID: %s", err.Error())).ServeHTTP(w, r)
problem.NewBadRequest(fmt.Sprintf("Incorrectly encoded project ID: %s", err.Error())).ServeHTTP(w, r)
return
}
publicationID, err := uuid.ParseBytes(id)
projectID, err := uuid.ParseBytes(id)
if err != nil {
problem.NewBadRequest("Publication ID is not a valid UUID").ServeHTTP(w, r)
problem.NewBadRequest("Project ID is not a valid UUID").ServeHTTP(w, r)
return
}
publication, err := ctrl.publicationSvc.Get(publicationID)
project, err := ctrl.projectSvc.GetProject(projectID)
if errors.Is(err, service.ErrNotFound) {
problem.NewNotFound().ServeHTTP(w, r)
return
@@ -96,8 +96,8 @@ func (ctrl publicationController) getPublication(w http.ResponseWriter, r *http.
return
}
// TODO: Return publication template
b, err := json.Marshal(publication)
// TODO: Return project template
b, err := json.Marshal(project)
w.Header().Add("Content-Type", "application/json")
if _, err := w.Write(b); err != nil {
@@ -106,7 +106,7 @@ func (ctrl publicationController) getPublication(w http.ResponseWriter, r *http.
}
}
func (ctrl publicationController) createPublication(w http.ResponseWriter, r *http.Request) {
func (ctrl projectController) createProject(w http.ResponseWriter, r *http.Request) {
userCtx := NewUserContext(r.Context())
userID, ok := userCtx.GetUserID()
@@ -121,12 +121,12 @@ func (ctrl publicationController) createPublication(w http.ResponseWriter, r *ht
return
}
publication, err := ctrl.publicationSvc.Create(title, userID)
project, err := ctrl.projectSvc.Create(title, userID)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
path := fmt.Sprintf("/publication/%s/", base64.URLEncoding.EncodeToString([]byte(publication.ID.String())))
path := fmt.Sprintf("/p/%s/", base64.URLEncoding.EncodeToString([]byte(project.ID.String())))
http.Redirect(w, r, path, http.StatusSeeOther)
}

View File

@@ -5,6 +5,7 @@ import (
"io/fs"
"log/slog"
"net/http"
"strings"
"code.capytal.cc/capytal/comicverse/service"
"code.capytal.cc/capytal/comicverse/templates"
@@ -16,9 +17,9 @@ import (
)
type router struct {
userService *service.User
tokenService *service.Token
publicationService *service.Publication
userService *service.User
tokenService *service.Token
projectService *service.Project
templates templates.ITemplate
assets fs.FS
@@ -35,8 +36,8 @@ func New(cfg Config) (http.Handler, error) {
if cfg.TokenService == nil {
return nil, errors.New("token service is nil")
}
if cfg.PublicationService == nil {
return nil, errors.New("publication service is nil")
if cfg.ProjectService == nil {
return nil, errors.New("project service is nil")
}
if cfg.Templates == nil {
return nil, errors.New("templates is nil")
@@ -52,9 +53,9 @@ func New(cfg Config) (http.Handler, error) {
}
r := &router{
userService: cfg.UserService,
tokenService: cfg.TokenService,
publicationService: cfg.PublicationService,
userService: cfg.UserService,
tokenService: cfg.TokenService,
projectService: cfg.ProjectService,
templates: cfg.Templates,
assets: cfg.Assets,
@@ -68,9 +69,9 @@ func New(cfg Config) (http.Handler, error) {
}
type Config struct {
UserService *service.User
TokenService *service.Token
PublicationService *service.Publication
UserService *service.User
TokenService *service.Token
ProjectService *service.Project
Templates templates.ITemplate
Assets fs.FS
@@ -88,16 +89,8 @@ func (router *router) setup() http.Handler {
log.Debug("Initializing router")
mux := multiplexer.New()
mux = multiplexer.WithFormMethod(mux, "x-method")
mux = multiplexer.WithPatternRules(mux,
multiplexer.EnsureMethod(),
multiplexer.EnsureStrictEnd(),
multiplexer.EnsureTrailingSlash(),
)
r := smalltrip.NewRouter(
smalltrip.WithMultiplexer(mux),
smalltrip.WithAssertions(router.assert),
smalltrip.WithLogger(log.WithGroup("smalltrip")),
)
@@ -121,17 +114,17 @@ func (router *router) setup() http.Handler {
Templates: router.templates,
Assert: router.assert,
})
publicationController := newPublicationController(router.publicationService, router.templates, router.assert)
projectController := newProjectController(router.projectService, router.templates, router.assert)
r.Handle("GET /assets/{_file...}", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.Use(userController.userMiddleware)
r.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
// TODO: Add a way to the user to bypass this check and see the landing page.
// Probably a query parameter to bypass like "?landing=true"
if _, ok := NewUserContext(r.Context()).GetUserID(); ok {
publicationController.dashboard(w, r)
projectController.dashboard(w, r)
return
}
@@ -141,14 +134,23 @@ func (router *router) setup() http.Handler {
}
})
r.HandleFunc("GET /login/{$}", userController.login)
r.HandleFunc("POST /login/{$}", userController.login)
r.HandleFunc("GET /register/{$}", userController.register)
r.HandleFunc("POST /register/{$}", userController.register)
r.HandleFunc("/login/{$}", userController.login)
r.HandleFunc("/register/{$}", userController.register)
// TODO: Provide/redirect short publication-id paths to long paths with the publication title as URL /publications/title-of-the-publication-<start of uuid>
r.HandleFunc("GET /publication/{publicationID}/{$}", publicationController.getPublication)
r.HandleFunc("POST /publication/{$}", publicationController.createPublication)
// TODO: Provide/redirect short project-id paths to long paths with the project title as URL /projects/title-of-the-project-<start of uuid>
r.HandleFunc("GET /p/{projectID}/{$}", projectController.getProject)
r.HandleFunc("POST /p/{$}", projectController.createProject)
return r
}
// getMethod is a helper function to get the HTTP method of request, tacking precedence
// the "x-method" argument sent by requests via form or query values.
func getMethod(r *http.Request) string {
m := r.FormValue("x-method")
if m != "" {
return strings.ToUpper(m)
}
return strings.ToUpper(r.Method)
}

124
service/project.go Normal file
View File

@@ -0,0 +1,124 @@
package service
import (
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/capytal/comicverse/repository"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Project struct {
projectRepo *repository.Project
permissionRepo *repository.Permissions
log *slog.Logger
assert tinyssert.Assertions
}
func NewProject(
project *repository.Project,
permissions *repository.Permissions,
logger *slog.Logger,
assertions tinyssert.Assertions,
) *Project {
return &Project{
projectRepo: project,
permissionRepo: permissions,
log: logger,
assert: assertions,
}
}
func (svc Project) Create(title string, ownerUserID ...uuid.UUID) (model.Project, error) {
log := svc.log.With(slog.String("title", title))
log.Info("Creating project")
defer log.Info("Finished creating project")
id, err := uuid.NewV7()
if err != nil {
return model.Project{}, fmt.Errorf("service: failed to generate id: %w", err)
}
now := time.Now()
p := model.Project{
ID: id,
Title: title,
DateCreated: now,
DateUpdated: now,
}
err = svc.projectRepo.Create(p)
if err != nil {
return model.Project{}, fmt.Errorf("service: failed to create project: %w", err)
}
if len(ownerUserID) > 0 {
err := svc.SetAuthor(p.ID, ownerUserID[0])
if err != nil {
return model.Project{}, err
}
}
return p, nil
}
func (svc Project) SetAuthor(projectID uuid.UUID, userID uuid.UUID) error {
log := svc.log.With(slog.String("project", projectID.String()), slog.String("user", userID.String()))
log.Info("Setting project owner")
defer log.Info("Finished setting project owner")
if _, err := svc.permissionRepo.GetByID(projectID, userID); err == nil {
err := svc.permissionRepo.Update(projectID, userID, model.PermissionAuthor)
if err != nil {
return fmt.Errorf("service: failed to update project author: %w", err)
}
}
p := model.PermissionAuthor
err := svc.permissionRepo.Create(projectID, userID, p)
if err != nil {
return fmt.Errorf("service: failed to set project owner: %w", err)
}
return nil
}
func (svc Project) GetUserProjects(userID uuid.UUID) ([]model.Project, error) {
perms, err := svc.permissionRepo.GetByUserID(userID)
if err != nil {
return nil, fmt.Errorf("service: failed to get user permissions: %w", err)
}
ids := []uuid.UUID{}
for project, permissions := range perms {
if permissions.Has(model.PermissionRead) {
ids = append(ids, project)
}
}
if len(ids) == 0 {
return []model.Project{}, nil
}
projects, err := svc.projectRepo.GetByIDs(ids)
if err != nil {
return nil, fmt.Errorf("service: failed to get projects: %w", err)
}
return projects, nil
}
func (svc Project) GetProject(projectID uuid.UUID) (model.Project, error) {
p, err := svc.projectRepo.GetByID(projectID)
if err != nil {
return model.Project{}, fmt.Errorf("service: failed to get project: %w", err)
}
return p, nil
}

View File

@@ -1,124 +0,0 @@
package service
import (
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/capytal/comicverse/repository"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Publication struct {
publicationRepo *repository.Publication
permissionRepo *repository.Permissions
log *slog.Logger
assert tinyssert.Assertions
}
func NewPublication(
publication *repository.Publication,
permissions *repository.Permissions,
logger *slog.Logger,
assertions tinyssert.Assertions,
) *Publication {
return &Publication{
publicationRepo: publication,
permissionRepo: permissions,
log: logger,
assert: assertions,
}
}
func (svc Publication) Get(publicationID uuid.UUID) (model.Publication, error) {
p, err := svc.publicationRepo.GetByID(publicationID)
if err != nil {
return model.Publication{}, fmt.Errorf("service: failed to get publication: %w", err)
}
return p, nil
}
func (svc Publication) Create(title string, ownerUserID ...uuid.UUID) (model.Publication, error) {
log := svc.log.With(slog.String("title", title))
log.Info("Creating publication")
defer log.Info("Finished creating publication")
id, err := uuid.NewV7()
if err != nil {
return model.Publication{}, fmt.Errorf("service: failed to generate id: %w", err)
}
now := time.Now()
p := model.Publication{
ID: id,
Title: title,
DateCreated: now,
DateUpdated: now,
}
err = svc.publicationRepo.Create(p)
if err != nil {
return model.Publication{}, fmt.Errorf("service: failed to create publication: %w", err)
}
if len(ownerUserID) > 0 {
err := svc.SetAuthor(p.ID, ownerUserID[0])
if err != nil {
return model.Publication{}, err
}
}
return p, nil
}
func (svc Publication) SetAuthor(publicationID uuid.UUID, userID uuid.UUID) error {
log := svc.log.With(slog.String("publication", publicationID.String()), slog.String("user", userID.String()))
log.Info("Setting publication owner")
defer log.Info("Finished setting publication owner")
if _, err := svc.permissionRepo.GetByID(publicationID, userID); err == nil {
err := svc.permissionRepo.Update(publicationID, userID, model.PermissionAuthor)
if err != nil {
return fmt.Errorf("service: failed to update publication author: %w", err)
}
}
p := model.PermissionAuthor
err := svc.permissionRepo.Create(publicationID, userID, p)
if err != nil {
return fmt.Errorf("service: failed to set publication owner: %w", err)
}
return nil
}
func (svc Publication) ListOwnedBy(userID uuid.UUID) ([]model.Publication, error) {
perms, err := svc.permissionRepo.GetByUserID(userID)
if err != nil {
return nil, fmt.Errorf("service: failed to get user permissions: %w", err)
}
ids := []uuid.UUID{}
for publication, permissions := range perms {
if permissions.Has(model.PermissionRead) {
ids = append(ids, publication)
}
}
if len(ids) == 0 {
return []model.Publication{}, nil
}
publications, err := svc.publicationRepo.GetByIDs(ids)
if err != nil {
return nil, fmt.Errorf("service: failed to get publications: %w", err)
}
return publications, nil
}

View File

@@ -4,12 +4,12 @@
{{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">Publications</h2>
<form action="/publication/" method="post">
<h2 class="text-2xl">Projects</h2>
<form action="/p/" method="post">
<button
class="rounded-full bg-slate-700 p-1 px-3 text-sm text-slate-100"
>
New publication
New project
</button>
</form>
</div>
@@ -20,11 +20,11 @@
<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="/publication/{{.ID}}/">
<a href="/p/{{.ID}}/">
<h3>{{.Title}}</h3>
<p class="hidden">{{.ID}}</p>
</a>
<form action="/publication/{{.ID}}/" method="post">
<form action="/p/{{.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"
@@ -41,21 +41,16 @@
<div
class="fixed flex h-screen w-full items-center justify-center top-0 left-0"
>
<form
action="/publication/"
method="post"
class="bg-slate-300 rounded-full"
>
<form action="/p/" method="post" class="bg-slate-300 rounded-full">
<input
type="text"
name="title"
placeholder="Publication title"
value="{{randomName}}"
placeholder="Project title"
required
class="pl-5"
/>
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
New publication
New project
</button>
</form>
</div>

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

View File

@@ -1,111 +0,0 @@
{{define "publication"}} {{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="/publications/{{$.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="/publications/{{$.ID}}/pages/{{$page.ID}}/"
class="z-1 relative"
/>
</div>
{{else}}
<img
src="/publications/{{$.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="/publications/{{$.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="/publications/{{$.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="/publications/{{.ID}}/pages/"
method="post"
enctype="multipart/form-data"
>
<input type="file" name="image" required />
<button>Add new page</button>
</form>
</div>
</main>
</div>
{{template "layout-page-end"}} {{end}}

View File

@@ -1 +0,0 @@
{{define "publications"}} {{end}}

View File

@@ -9,8 +9,6 @@ import (
"html/template"
"io"
"io/fs"
"code.capytal.cc/capytal/comicverse/internals/randname"
)
var (
@@ -34,9 +32,6 @@ var (
return m, nil
},
"randomName": func() string {
return randname.New()
},
}
)

2
x

Submodule x updated: 2ce5d71249...6ea200aa64