4 Commits

23 changed files with 1348 additions and 357 deletions

View File

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

3
editor/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module code.capytal.cc/capytal/comicverse/editor
go 1.25.2

0
editor/go.sum Normal file
View File

View File

@@ -2,6 +2,7 @@ go 1.25.2
use (
.
./editor
./smalltrip
./x
)

View File

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

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

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

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

@@ -120,7 +120,7 @@ const (
PermissionAuthor Permissions = 0x1111111111111111 // "author"
PermissionAdminDelete Permissions = 0x1000000000000000 // "admin.delete" -----
PermissionAdminAll Permissions = 0x0111110000000001 // "admin.all"
PermissionAdminProject Permissions = 0x0100000000000000 // "admin.project"
PermissionAdminPublication Permissions = 0x0100000000000000 // "admin.publication"
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",
PermissionAdminProject: "admin.project",
PermissionAdminPublication: "admin.publication",
PermissionAdminMembers: "admin.members",
PermissionEditPages: "edit.pages",
PermissionEditInteractions: "edit.interactions",

View File

@@ -6,16 +6,16 @@ import (
"github.com/google/uuid"
)
type Project struct {
type Publication 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 = (*Project)(nil)
var _ Model = (*Publication)(nil)
func (p Project) Validate() error {
func (p Publication) Validate() error {
errs := []error{}
if len(p.ID) == 0 {
errs = append(errs, ErrZeroValue{Name: "UUID"})
@@ -31,7 +31,7 @@ func (p Project) Validate() error {
}
if len(errs) > 0 {
return ErrInvalidModel{Name: "Project", Errors: errs}
return ErrInvalidModel{Name: "Publication", Errors: errs}
}
return nil

View File

@@ -17,7 +17,7 @@ type Permissions struct {
baseRepostiory
}
// Must be initiated after [User] and [Project]
// Must be initiated after [User] and [Publication]
func NewPermissions(
ctx context.Context,
db *sql.DB,
@@ -32,17 +32,17 @@ func NewPermissions(
}
q := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS project_permissions (
project_id TEXT NOT NULL,
CREATE TABLE IF NOT EXISTS publication_permissions (
publication_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(project_id, user_id)
FOREIGN KEY(project_id)
REFERENCES projects (id)
PRIMARY KEY(publication_id, user_id)
FOREIGN KEY(publication_id)
REFERENCES publications (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 project tables"), err)
return nil, errors.Join(errors.New("unable to create publication tables"), err)
}
return &Permissions{baseRepostiory: b}, nil
}
func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permissions) error {
func (repo Permissions) Create(publication, 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(project, user uuid.UUID, permissions model.Permis
}
q := `
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)
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)
`
now := time.Now()
log := repo.log.With(slog.String("project_id", project.String()),
log := repo.log.With(slog.String("publication_id", publication.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 project permissions")
log.DebugContext(repo.ctx, "Inserting new publication permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("project_id", project),
sql.Named("publication_id", publication),
sql.Named("user_id", user),
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
@@ -97,7 +97,7 @@ func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permis
sql.Named("updated_at", now.Format(dateFormat)),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert project permissions", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to insert publication permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -109,24 +109,24 @@ func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permis
return nil
}
func (repo Permissions) GetByID(project uuid.UUID, user uuid.UUID) (model.Permissions, error) {
func (repo Permissions) GetByID(publication 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 project_permissions
WHERE project_id = :project_id
SELECT permissions_value FROM publication_permissions
WHERE publication_id = :publication_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("projcet_id", project.String()),
log := repo.log.With(slog.String("projcet_id", publication.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("project_id", user),
sql.Named("publication_id", user),
sql.Named("user_id", user))
var p model.Permissions
@@ -138,7 +138,7 @@ func (repo Permissions) GetByID(project uuid.UUID, user uuid.UUID) (model.Permis
return p, nil
}
// GetByUserID returns a project_id-to-permissions map containing all projects and permissions that said userID
// GetByUserID returns a publication_id-to-permissions map containing all publications 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 project_id, permissions_value FROM project_permissions
SELECT publication_id, permissions_value FROM publication_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 project uuid.UUID
var publication uuid.UUID
var permissions model.Permissions
err := rows.Scan(&project, &permissions)
err := rows.Scan(&publication, &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[project] = permissions
ps[publication] = 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(project, user uuid.UUID, permissions model.Permissions) error {
func (repo Permissions) Update(publication, 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(project, user uuid.UUID, permissions model.Permis
}
q := `
UPDATE project_permissions
UPDATE publication_permissions
SET permissions_value = :permissions_value
_permissions_text = :permissions_text
updated_at = :updated_at
WHERE project_uuid = :project_uuid
WHERE publication_uuid = :publication_uuid
AND user_uuid = :user_uuid
`
log := repo.log.With(slog.String("project_id", project.String()),
log := repo.log.With(slog.String("publication_id", publication.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 project permissions")
log.DebugContext(repo.ctx, "Updating publication permissions")
now := time.Now()
@@ -228,11 +228,11 @@ func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permis
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
sql.Named("updated_at", now.Format(dateFormat)),
sql.Named("project_id", project),
sql.Named("publication_id", publication),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to update project permissions", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to update publication permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -244,7 +244,7 @@ func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permis
return nil
}
func (repo Permissions) Delete(project, user uuid.UUID) error {
func (repo Permissions) Delete(publication, 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(project, user uuid.UUID) error {
}
q := `
DELETE FROM project_permissions
WHERE project_id = :project_id
DELETE FROM publication_permissions
WHERE publication_id = :publication_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("project_id", project.String()),
log := repo.log.With(slog.String("publication_id", publication.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting project permissions")
log.DebugContext(repo.ctx, "Deleting publication permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("project_id", project),
sql.Named("publication_id", publication),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete project permissions", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to delete publication permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}

View File

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

View File

@@ -35,6 +35,7 @@ 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

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

View File

@@ -5,7 +5,6 @@ import (
"io/fs"
"log/slog"
"net/http"
"strings"
"code.capytal.cc/capytal/comicverse/service"
"code.capytal.cc/capytal/comicverse/templates"
@@ -17,9 +16,9 @@ import (
)
type router struct {
userService *service.User
tokenService *service.Token
projectService *service.Project
userService *service.User
tokenService *service.Token
publicationService *service.Publication
templates templates.ITemplate
assets fs.FS
@@ -36,8 +35,8 @@ func New(cfg Config) (http.Handler, error) {
if cfg.TokenService == nil {
return nil, errors.New("token service is nil")
}
if cfg.ProjectService == nil {
return nil, errors.New("project service is nil")
if cfg.PublicationService == nil {
return nil, errors.New("publication service is nil")
}
if cfg.Templates == nil {
return nil, errors.New("templates is nil")
@@ -53,9 +52,9 @@ func New(cfg Config) (http.Handler, error) {
}
r := &router{
userService: cfg.UserService,
tokenService: cfg.TokenService,
projectService: cfg.ProjectService,
userService: cfg.UserService,
tokenService: cfg.TokenService,
publicationService: cfg.PublicationService,
templates: cfg.Templates,
assets: cfg.Assets,
@@ -69,9 +68,9 @@ func New(cfg Config) (http.Handler, error) {
}
type Config struct {
UserService *service.User
TokenService *service.Token
ProjectService *service.Project
UserService *service.User
TokenService *service.Token
PublicationService *service.Publication
Templates templates.ITemplate
Assets fs.FS
@@ -89,8 +88,16 @@ 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.WithAssertions(router.assert),
smalltrip.WithMultiplexer(mux),
smalltrip.WithLogger(log.WithGroup("smalltrip")),
)
@@ -114,17 +121,17 @@ func (router *router) setup() http.Handler {
Templates: router.templates,
Assert: router.assert,
})
projectController := newProjectController(router.projectService, router.templates, router.assert)
publicationController := newPublicationController(router.publicationService, router.templates, router.assert)
r.Handle("/assets/", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.Handle("GET /assets/{_file...}", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
r.Use(userController.userMiddleware)
r.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("GET /{$}", 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 {
projectController.dashboard(w, r)
publicationController.dashboard(w, r)
return
}
@@ -134,23 +141,14 @@ func (router *router) setup() http.Handler {
}
})
r.HandleFunc("/login/{$}", userController.login)
r.HandleFunc("/register/{$}", userController.register)
r.HandleFunc("GET /login/{$}", userController.login)
r.HandleFunc("POST /login/{$}", userController.login)
r.HandleFunc("GET /register/{$}", userController.register)
r.HandleFunc("POST /register/{$}", userController.register)
// 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)
// 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)
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)
}

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

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

View File

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

View File

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

111
templates/publication.html Normal file
View File

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

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

View File

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