A simple way to edit images #7

Merged
lmika merged 8 commits from feature/image-edit into main 2026-03-28 10:47:32 +00:00
21 changed files with 248 additions and 22 deletions
Showing only changes of commit c8a276b248 - Show all commits

View file

@ -1,3 +1,4 @@
import feather from "feather-icons/dist/feather.js";
import Handlebars from "handlebars";
import {Controller} from "@hotwired/stimulus";
@ -7,12 +8,12 @@ Handlebars.registerHelper("submit_on", function (id, event) {
const processorFrame = Handlebars.compile(`
<div class="card mb-3">
<div class="card-header d-flex justify-content-between">
<div class="card-header d-flex justify-content-between align-items-center">
<span>{{name}}</span>
<a href="#" class="btn btn-sm btn-secondary float-end"
<a href="#" class="float-end"
data-action="edit-upload#removeProcessor"
data-edit-upload-id-param="{{id}}"
>X</a>
><i data-feather="x" width="18" height="18"></i></a>
</div>
<div class="card-body">
<form data-role="processor-params" data-params-id="{{id}}">{{{props}}}</form>
@ -78,6 +79,16 @@ export default class UploadEditController extends Controller {
await this._removeProcessor(id);
}
async saveUpload(ev) {
ev.preventDefault();
await this._save("replace");
}
async saveNewUpload(ev) {
ev.preventDefault();
await this._save("copy");
}
async updateProcessor(ev) {
ev.preventDefault();
let id = ev.params.id;
@ -108,6 +119,8 @@ export default class UploadEditController extends Controller {
});
el.innerHTML += cardOuter;
}
feather.replace();
}
async _createSession() {
@ -179,6 +192,33 @@ export default class UploadEditController extends Controller {
})
}
async _save(mode) {
if (!this._state || !this._state.session) {
return;
}
try {
let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/save`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ mode })
});
if (!resp.ok) {
console.error("Save failed:", resp.statusText);
return;
}
let result = await resp.json();
window.location.href = `/sites/${this.siteIdValue}/uploads/${result.upload_id}`;
} catch (e) {
console.error(e);
}
}
async _doReturningState(fn) {
try {
this._state = await fn();

View file

@ -156,6 +156,7 @@ Starting weiro without any arguments will start the server.
siteGroup.Patch("/imageedit/:sessionID", ieh.PatchSession)
siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor)
siteGroup.Delete("/imageedit/:sessionID/processors/:processorID", ieh.DeleteProcessor)
siteGroup.Post("/imageedit/:sessionID/save", ieh.Save)
siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview)
siteGroup.Get("/settings", ssh.General)

View file

@ -114,6 +114,27 @@ func (ieh ImageEditHandlers) DeleteProcessor(c fiber.Ctx) error {
})
}
func (ieh ImageEditHandlers) Save(c fiber.Ctx) error {
sessionID := c.Params("sessionID")
if sessionID == "" {
return fiber.ErrBadRequest
}
var req struct {
Mode string `json:"mode"`
}
if err := c.Bind().JSON(&req); err != nil {
return fiber.ErrBadRequest
}
result, err := ieh.ImageEditService.Save(c.Context(), sessionID, req.Mode)
if err != nil {
return err
}
return c.Status(http.StatusOK).JSON(result)
}
func (ieh ImageEditHandlers) PatchSession(c fiber.Ctx) error {
var req struct {
UpdateProc *imgedit.UpdateProcessorReq `json:"processor"`

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: categories.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: pages.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: pending_uploads.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: posts.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: pubtargets.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: sites.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: uploads.sql
package sqlgen
@ -18,7 +18,7 @@ func (q *Queries) DeleteUpload(ctx context.Context, id int64) error {
return err
}
const insertUpload = `-- name: InsertUpload :exec
const insertUpload = `-- name: InsertUpload :one
INSERT INTO uploads (
site_id,
guid,
@ -43,8 +43,8 @@ type InsertUploadParams struct {
CreatedAt int64
}
func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) error {
_, err := q.db.ExecContext(ctx, insertUpload,
func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertUpload,
arg.SiteID,
arg.Guid,
arg.MimeType,
@ -54,7 +54,9 @@ func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) erro
arg.Alt,
arg.CreatedAt,
)
return err
var id int64
err := row.Scan(&id)
return id, err
}
const selectUploadByID = `-- name: SelectUploadByID :one
@ -154,3 +156,17 @@ func (q *Queries) UpdateUpload(ctx context.Context, arg UpdateUploadParams) erro
_, err := q.db.ExecContext(ctx, updateUpload, arg.Alt, arg.ID)
return err
}
const updateUploadFileSize = `-- name: UpdateUploadFileSize :exec
UPDATE uploads SET file_size = ? WHERE id = ?
`
type UpdateUploadFileSizeParams struct {
FileSize int64
ID int64
}
func (q *Queries) UpdateUploadFileSize(ctx context.Context, arg UpdateUploadFileSizeParams) error {
_, err := q.db.ExecContext(ctx, updateUploadFileSize, arg.FileSize, arg.ID)
return err
}

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: users.sql
package sqlgen

View file

@ -44,7 +44,7 @@ func (db *Provider) SelectUploadBySiteIDAndSlug(ctx context.Context, siteID int6
func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error {
if upload.ID == 0 {
if err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{
newID, err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{
SiteID: upload.SiteID,
Guid: upload.GUID,
MimeType: upload.MIMEType,
@ -53,9 +53,11 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error
Slug: upload.Slug,
Alt: upload.Alt,
CreatedAt: upload.CreatedAt.Unix(),
}); err != nil {
})
if err != nil {
return err
}
upload.ID = newID
return nil
}
@ -65,6 +67,13 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error
})
}
func (db *Provider) UpdateUploadFileSize(ctx context.Context, id int64, fileSize int64) error {
return db.queries.UpdateUploadFileSize(ctx, sqlgen.UpdateUploadFileSizeParams{
FileSize: fileSize,
ID: id,
})
}
func (db *Provider) DeleteUpload(ctx context.Context, id int64) error {
return db.queries.DeleteUpload(ctx, id)
}

View file

@ -66,6 +66,11 @@ func copyFile(src, dst string) error {
return err
}
func (p *Provider) ReplaceFile(site models.Site, up models.Upload, srcPath string) error {
fullPath := p.uploadFileName(site, up)
return copyFile(srcPath, fullPath)
}
func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) {
fullPath := p.uploadFileName(site, up)
return os.Open(fullPath)

View file

@ -175,6 +175,58 @@ func (s *Service) UpdateProcessor(ctx context.Context, sessionID string, req Upd
return session, nil
}
type SaveResult struct {
UploadID int64 `json:"upload_id"`
}
func (s *Service) Save(ctx context.Context, sessionID string, mode string) (*SaveResult, error) {
session, err := s.loadAndVerifySession(ctx, sessionID)
if err != nil {
return nil, err
}
if len(session.Processors) == 0 {
return nil, fmt.Errorf("no processors in session")
}
lastProc := session.Processors[len(session.Processors)-1]
finalImagePath := fmt.Sprintf("%v/%v/%v.%v", s.scratchDir, session.GUID, lastProc.VersionID, session.ImageExt)
var mimeType string
switch session.ImageExt {
case "jpg", "jpeg":
mimeType = "image/jpeg"
case "png":
mimeType = "image/png"
}
var uploadID int64
switch mode {
case "replace":
upload, err := s.uploadService.ReplaceUploadFile(ctx, session.BaseUploadID, finalImagePath)
if err != nil {
return nil, err
}
uploadID = upload.ID
case "copy":
baseUpload, _, err := s.uploadService.OpenUpload(ctx, session.BaseUploadID)
if err != nil {
return nil, err
}
upload, err := s.uploadService.CreateUploadFromFile(ctx, finalImagePath, baseUpload.Filename, mimeType)
if err != nil {
return nil, err
}
uploadID = upload.ID
default:
return nil, fmt.Errorf("unknown save mode: %v", mode)
}
s.sessionStore.delete(session.GUID)
return &SaveResult{UploadID: uploadID}, nil
}
func (s *Service) loadAndVerifySession(ctx context.Context, sessionID string) (*models.ImageEditSession, error) {
site, user, err := s.fetchSiteAndUser(ctx)
if err != nil {

View file

@ -42,6 +42,10 @@ func (ss *sessionStore) get(guid string) (*models.ImageEditSession, error) {
return &sessionData, nil
}
func (ss *sessionStore) delete(guid string) {
os.RemoveAll(filepath.Join(ss.baseDir, guid))
}
func (ss *sessionStore) getImage(session *models.ImageEditSession, imageFilename string) (string, func() (io.ReadCloser, error), error) {
fullPath := filepath.Join(ss.baseDir, session.GUID, imageFilename)
if s, err := os.Stat(fullPath); err != nil {

View file

@ -6,7 +6,10 @@ import (
"html/template"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"lmika.dev/lmika/weiro/models"
)
@ -67,6 +70,75 @@ func (s *Service) renderCopyTemplate(upload models.Upload) string {
return sb.String()
}
func (s *Service) ReplaceUploadFile(ctx context.Context, uploadID int64, srcPath string) (models.Upload, error) {
site, _, err := s.fetchSiteAndUser(ctx)
if err != nil {
return models.Upload{}, err
}
upload, err := s.db.SelectUploadByID(ctx, uploadID)
if err != nil {
return models.Upload{}, err
} else if upload.SiteID != site.ID {
return models.Upload{}, models.NotFoundError
}
if err := s.up.ReplaceFile(site, upload, srcPath); err != nil {
return models.Upload{}, err
}
stat, err := os.Stat(srcPath)
if err != nil {
return models.Upload{}, err
}
upload.FileSize = stat.Size()
if err := s.db.UpdateUploadFileSize(ctx, upload.ID, upload.FileSize); err != nil {
return models.Upload{}, err
}
return upload, nil
}
func (s *Service) CreateUploadFromFile(ctx context.Context, srcPath string, filename string, mimeType string) (models.Upload, error) {
site, _, err := s.fetchSiteAndUser(ctx)
if err != nil {
return models.Upload{}, err
}
stat, err := os.Stat(srcPath)
if err != nil {
return models.Upload{}, err
}
newUploadGUID := models.NewNanoID()
newTime := time.Now().UTC()
newSlug := filepath.Join(
fmt.Sprintf("%04d", newTime.Year()),
fmt.Sprintf("%02d", newTime.Month()),
newUploadGUID+filepath.Ext(filename),
)
newUpload := models.Upload{
SiteID: site.ID,
GUID: models.NewNanoID(),
FileSize: stat.Size(),
MIMEType: mimeType,
Filename: filename,
CreatedAt: newTime,
Slug: newSlug,
}
if err := s.db.SaveUpload(ctx, &newUpload); err != nil {
return models.Upload{}, err
}
if err := s.up.AdoptFile(site, newUpload, srcPath); err != nil {
return models.Upload{}, err
}
return newUpload, nil
}
func (s *Service) ListUploads(ctx context.Context) (res []UploadWithURL, _ error) {
site, _, err := s.fetchSiteAndUser(ctx)
if err != nil {

View file

@ -7,7 +7,7 @@ SELECT * FROM uploads WHERE id = ? LIMIT 1;
-- name: SelectUploadBySiteIDAndSlug :one
SELECT * FROM uploads WHERE site_id = ? AND slug = ? LIMIT 1;
-- name: InsertUpload :exec
-- name: InsertUpload :one
INSERT INTO uploads (
site_id,
guid,
@ -23,5 +23,8 @@ RETURNING id;
-- name: UpdateUpload :exec
UPDATE uploads SET alt = ? WHERE id = ?;
-- name: UpdateUploadFileSize :exec
UPDATE uploads SET file_size = ? WHERE id = ?;
-- name: DeleteUpload :exec
DELETE FROM uploads WHERE id = ?;

View file

@ -24,7 +24,7 @@
</div>
</div>
</div>
<div class="col-md-9">
<div class="col-md-9 m-3">
<button class="btn btn-primary" data-action="edit-upload#saveUpload">Save</button>
<button class="btn btn-secondary" data-action="edit-upload#saveNewUpload">Save as Copy</button>
</div>

View file

@ -5,7 +5,10 @@
data-show-upload-site-id-value="{{ .upload.Upload.SiteID }}"
data-show-upload-upload-id-value="{{ .upload.Upload.ID }}">
<button class="btn btn-outline-dark" data-action="show-upload#copy">Copy HTML</button>
<button class="btn btn-danger" data-action="show-upload#delete">Delete</button>
<span>
<a href="/sites/{{ .site.ID }}/uploads/{{ .upload.Upload.ID }}/edit" class="btn btn-secondary">Edit</a>
<button class="btn btn-danger" data-action="show-upload#delete">Delete</button>
</span>
</div>
<figure>