diff --git a/assets/css/main.scss b/assets/css/main.scss
index 2e0883a..addf5ce 100644
--- a/assets/css/main.scss
+++ b/assets/css/main.scss
@@ -31,19 +31,24 @@ $container-max-widths: (
font-size: 0.9rem;
}
-// Post form
+// Large editor
+//
+// Used for edit canvases which take up the entire window
-// Post edit page styling
-.post-edit-page {
+.large-editor {
height: 100vh;
}
-.post-edit-page main {
+.large-editor main {
display: flex;
flex-direction: column;
overflow: hidden;
}
+// Post form
+
+// Post edit page styling
+
.post-edit-page .post-form {
flex: 1;
display: flex;
diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js
new file mode 100644
index 0000000..95cbb1e
--- /dev/null
+++ b/assets/js/controllers/edit_upload.js
@@ -0,0 +1,233 @@
+import feather from "feather-icons/dist/feather.js";
+import Handlebars from "handlebars";
+import {Controller} from "@hotwired/stimulus";
+
+Handlebars.registerHelper("submit_on", function (id, event) {
+ return `data-action="${event}->edit-upload#updateProcessor" data-edit-upload-id-param="${id}"`
+});
+
+const processorFrame = Handlebars.compile(`
+
+`);
+
+const processorUIs = {
+ "shadow": {
+ label: "Shadow",
+ template: Handlebars.compile(`
+
+
+ `),
+ },
+ "resize": {
+ label: "Resize",
+ template: Handlebars.compile(`
+
+
+
+
+
+
+
+
+ `),
+ },
+};
+
+export default class UploadEditController extends Controller {
+ static targets = ['processList', 'preview'];
+ static values = {
+ uploadId: Number,
+ siteId: Number,
+ };
+
+ connect() {
+ this._rebuildProcessList();
+ this._createSession();
+ }
+
+ async addProcessor(ev) {
+ ev.preventDefault();
+ await this._addProcessor({
+ type: "shadow"
+ });
+ }
+
+ async removeProcessor(ev) {
+ ev.preventDefault();
+ let id = ev.params.id;
+ 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;
+
+ let paramParentEl = ev.target.closest('[data-role="processor-params"]');
+ let params = Object.fromEntries(new FormData(paramParentEl).entries());
+
+ await this._updateProcessor(id, params);
+ }
+
+ _rebuildProcessList() {
+ let el = this.processListTarget;
+
+ if ((!this._state) || (!this._state.session) || (!this._state.session.processors)) {
+ return;
+ }
+
+ el.innerHTML = "";
+ for (let p of this._state.session.processors) {
+ let ui = processorUIs[p.type];
+ if (!ui) {
+ continue;
+ }
+ let cardOuter = processorFrame({
+ id: p.id,
+ name: ui.label,
+ props: ui.template(p),
+ });
+ el.innerHTML += cardOuter;
+ }
+
+ feather.replace();
+ }
+
+ async _createSession() {
+ try {
+ let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/`, {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ "base_upload": this.uploadIdValue,
+ })
+ });
+
+ this._state = await resp.json();
+
+ this._rebuildProcessList();
+ this.previewTarget.src = this._state.preview_url;
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ async _addProcessor(processor) {
+ try {
+ let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors`, {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(processor)
+ });
+
+ this._state = await resp.json();
+
+ this._rebuildProcessList();
+ this.previewTarget.src = this._state.preview_url;
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ async _updateProcessor(processorID, params) {
+ await this._doReturningState(async () => {
+ return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}`, {
+ method: 'PATCH',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ processor: {
+ id: processorID,
+ props: params,
+ }
+ })
+ })).json();
+ })
+ }
+
+
+ async _removeProcessor(processorID) {
+ await this._doReturningState(async () => {
+ return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors/${processorID}`, {
+ method: 'DELETE',
+ })).json();
+ })
+ }
+
+ 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();
+
+ this._rebuildProcessList();
+ this.previewTarget.src = this._state.preview_url;
+ } catch (e) {
+ console.error(e);
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/assets/js/main.js b/assets/js/main.js
index fcbe286..d3ff4c6 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -8,6 +8,7 @@ import LogoutController from "./controllers/logout";
import FirstRunController from "./controllers/firstrun";
import UploadController from "./controllers/upload";
import ShowUploadController from "./controllers/show_upload";
+import EditUploadController from "./controllers/edit_upload";
import PagelistController from "./controllers/pagelist";
window.Stimulus = Application.start()
@@ -18,6 +19,7 @@ Stimulus.register("logout", LogoutController);
Stimulus.register("first-run", FirstRunController);
Stimulus.register("upload", UploadController);
Stimulus.register("show-upload", ShowUploadController);
+Stimulus.register("edit-upload", EditUploadController);
Stimulus.register("pagelist", PagelistController);
feather.replace();
\ No newline at end of file
diff --git a/cmds/server.go b/cmds/server.go
index 36e5923..28e2ccc 100644
--- a/cmds/server.go
+++ b/cmds/server.go
@@ -111,6 +111,7 @@ Starting weiro without any arguments will start the server.
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories}
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
+ ieh := handlers.ImageEditHandlers{ImageEditService: svcs.ImageEdit}
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
pgh := handlers.PagesHandler{PageService: svcs.Pages}
@@ -149,6 +150,14 @@ Starting weiro without any arguments will start the server.
siteGroup.Post("/uploads/pending/:guid", uh.UploadPart)
siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete)
siteGroup.Delete("/uploads/:uploadID", uh.Delete)
+ siteGroup.Get("/uploads/:uploadID/edit", uh.Edit)
+
+ siteGroup.Post("/imageedit", ieh.Create)
+ 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)
siteGroup.Post("/settings", ssh.UpdateGeneral)
diff --git a/handlers/imageedit.go b/handlers/imageedit.go
new file mode 100644
index 0000000..27a01b0
--- /dev/null
+++ b/handlers/imageedit.go
@@ -0,0 +1,165 @@
+package handlers
+
+import (
+ "bufio"
+ "io"
+ "log"
+ "net/http"
+
+ "github.com/gofiber/fiber/v3"
+ "lmika.dev/lmika/weiro/models"
+ "lmika.dev/lmika/weiro/services/imgedit"
+)
+
+type ImageEditHandlers struct {
+ ImageEditService *imgedit.Service
+}
+
+type sessionResponse struct {
+ Session *models.ImageEditSession `json:"session"`
+ PreviewURL string `json:"preview_url"`
+}
+
+func (ieh ImageEditHandlers) Create(c fiber.Ctx) error {
+ var req struct {
+ BaseUploadID int64 `json:"base_upload"`
+ }
+
+ if err := c.Bind().JSON(&req); err != nil {
+ return err
+ }
+
+ res, err := ieh.ImageEditService.NewSession(c.Context(), req.BaseUploadID)
+ if err != nil {
+ return err
+ }
+
+ var resp = sessionResponse{
+ Session: res,
+ PreviewURL: res.PreviewURL(),
+ }
+
+ return c.Status(http.StatusCreated).JSON(resp)
+}
+
+func (ieh ImageEditHandlers) Preview(c fiber.Ctx) error {
+ log.Printf("Previewing image edit session %v/%v", c.Params("sessionID"), c.Params("versionID"))
+ sessionID := c.Params("sessionID")
+ versionID := c.Params("versionID")
+
+ mimeTime, rw, err := ieh.ImageEditService.LoadImageVersion(c.Context(), sessionID, versionID)
+ if err != nil {
+ return err
+ }
+
+ c.Set("Content-Type", mimeTime)
+ c.Status(http.StatusOK)
+ return c.SendStreamWriter(func(w *bufio.Writer) {
+ rw, err := rw()
+ if err != nil {
+ return
+ }
+ defer rw.Close()
+
+ _, err = io.Copy(w, rw)
+ if err != nil {
+ return
+ }
+ })
+}
+
+func (ieh ImageEditHandlers) AddProcessor(c fiber.Ctx) error {
+ sessionID := c.Params("sessionID")
+ if sessionID == "" {
+ log.Println("No session ID")
+ return fiber.ErrBadRequest
+ }
+
+ var req imgedit.AddProcessorReq
+ if err := c.Bind().Body(&req); err != nil {
+ log.Printf("Failed to parse request body: %v", err)
+ return fiber.ErrBadRequest
+ }
+
+ res, err := ieh.ImageEditService.AddProcessor(c.Context(), sessionID, req)
+ if err != nil {
+ return err
+ }
+
+ return c.Status(http.StatusOK).JSON(sessionResponse{
+ Session: res,
+ PreviewURL: res.PreviewURL(),
+ })
+}
+
+func (ieh ImageEditHandlers) DeleteProcessor(c fiber.Ctx) error {
+ sessionID := c.Params("sessionID")
+ if sessionID == "" {
+ return fiber.ErrBadRequest
+ }
+
+ processorID := c.Params("processorID")
+ if processorID == "" {
+ return fiber.ErrBadRequest
+ }
+
+ res, err := ieh.ImageEditService.DeleteProcessor(c.Context(), sessionID, processorID)
+ if err != nil {
+ return err
+ }
+
+ return c.Status(http.StatusOK).JSON(sessionResponse{
+ Session: res,
+ PreviewURL: res.PreviewURL(),
+ })
+}
+
+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"`
+ }
+
+ sessionID := c.Params("sessionID")
+ if sessionID == "" {
+ return fiber.ErrBadRequest
+ }
+
+ if err := c.Bind().Body(&req); err != nil {
+ return err
+ }
+ log.Printf("Got request: %v", *req.UpdateProc)
+
+ if req.UpdateProc != nil {
+ res, err := ieh.ImageEditService.UpdateProcessor(c.Context(), sessionID, *req.UpdateProc)
+ if err != nil {
+ return err
+ }
+ return c.Status(http.StatusOK).JSON(sessionResponse{
+ Session: res,
+ PreviewURL: res.PreviewURL(),
+ })
+ }
+
+ return fiber.ErrBadRequest
+}
diff --git a/handlers/index.go b/handlers/index.go
index 6062237..410c347 100644
--- a/handlers/index.go
+++ b/handlers/index.go
@@ -2,6 +2,7 @@ package handlers
import (
"fmt"
+ "log"
"net/url"
"regexp"
@@ -37,6 +38,13 @@ func (h IndexHandler) Index(c fiber.Ctx) error {
}
}
+ sess := session.FromContext(c)
+ lastSiteID, ok := sess.Get("last_site_id").(int64)
+ log.Printf("last site id: %v", lastSiteID)
+ if ok {
+ return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", lastSiteID))
+ }
+
site, err := h.SiteService.BestSite(c.Context(), user)
if err != nil {
return err
diff --git a/handlers/middleware/site.go b/handlers/middleware/site.go
index 6f47430..1d3ddf2 100644
--- a/handlers/middleware/site.go
+++ b/handlers/middleware/site.go
@@ -5,6 +5,7 @@ import (
"emperror.dev/errors"
"github.com/gofiber/fiber/v3"
+ "github.com/gofiber/fiber/v3/middleware/session"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/sites"
@@ -41,6 +42,9 @@ func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error {
}
c.Locals("allSites", sitesOwnedByUser)
+ sess := session.FromContext(c)
+ sess.Set("last_site_id", siteID)
+
if pubTargets, err := sites.BestPubTarget(c.Context(), site); err == nil {
c.Locals("pubTarget", pubTargets)
}
diff --git a/handlers/posts.go b/handlers/posts.go
index 3326533..0e491aa 100644
--- a/handlers/posts.go
+++ b/handlers/posts.go
@@ -75,7 +75,7 @@ func (ph PostsHandler) New(c fiber.Ctx) error {
"post": p,
"categories": cats,
"selectedCategories": map[int64]bool{},
- "bodyClass": "post-edit-page",
+ "bodyClass": "large-editor",
})
}
@@ -116,7 +116,7 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
"post": post,
"categories": cats,
"selectedCategories": selectedCategories,
- "bodyClass": "post-edit-page",
+ "bodyClass": "large-editor",
})
}))
}
diff --git a/handlers/uploads.go b/handlers/uploads.go
index fa2cb98..3553b09 100644
--- a/handlers/uploads.go
+++ b/handlers/uploads.go
@@ -162,3 +162,24 @@ func (uh UploadsHandler) UploadComplete(c fiber.Ctx) error {
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{})
}
+
+func (uh UploadsHandler) Edit(c fiber.Ctx) error {
+ uploadIDStr := c.Params("uploadID")
+ if uploadIDStr == "" {
+ return fiber.ErrBadRequest
+ }
+ uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64)
+ if err != nil {
+ return fiber.ErrBadRequest
+ }
+
+ upload, err := uh.UploadsService.FetchUpload(c.Context(), uploadID)
+ if err != nil {
+ return err
+ }
+
+ return c.Render("uploads/edit", fiber.Map{
+ "upload": upload,
+ "bodyClass": "large-editor",
+ })
+}
diff --git a/models/errors.go b/models/errors.go
index 3efadbc..2c4ae68 100644
--- a/models/errors.go
+++ b/models/errors.go
@@ -8,3 +8,4 @@ var NotFoundError = errors.New("not found")
var SiteRequiredError = errors.New("site required")
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
var SlugConflictError = errors.New("a record with this slug already exists")
+var UnsupportedImageFormat = errors.New("unsupported image format")
diff --git a/models/imgedit.go b/models/imgedit.go
new file mode 100644
index 0000000..b954402
--- /dev/null
+++ b/models/imgedit.go
@@ -0,0 +1,62 @@
+package models
+
+import (
+ "crypto/md5"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+)
+
+type ImageEditSession struct {
+ GUID string `json:"guid"`
+ SiteID int64 `json:"siteId"`
+ UserID int64 `json:"userId"`
+ BaseUploadID int64 `json:"baseUploadId"`
+ ImageExt string `json:"imageExt"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ Processors []ImageEditProcessor `json:"processors"`
+}
+
+func (ieh ImageEditSession) PreviewURL() string {
+ return fmt.Sprintf("/sites/%v/imageedit/%v/preview/%v", ieh.SiteID, ieh.GUID, ieh.Processors[len(ieh.Processors)-1].VersionID)
+}
+
+func (ieh *ImageEditSession) RecalcVersionIDs() {
+ for i, p := range ieh.Processors {
+ if i == 0 {
+ p.SetVersionID("")
+ } else {
+ p.SetVersionID(ieh.Processors[i-1].VersionID)
+ }
+
+ ieh.Processors[i] = p
+ }
+}
+
+type ImageEditProcessor struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Props json.RawMessage `json:"props"`
+
+ // VersionID is a unique hash of the particular processor. This includes the version ID of the previous processor,
+ // thereby causing a change of one processor to affect the version IDs of processors down the line.
+ VersionID string `json:"versionId"`
+}
+
+func (ieh *ImageEditProcessor) SetVersionID(previousVersionID string) {
+ var sb strings.Builder
+ sb.WriteString(ieh.ID)
+ sb.WriteString("-")
+ sb.WriteString(previousVersionID)
+ sb.WriteString("-")
+ sb.WriteString(ieh.Type)
+ sb.WriteString("-")
+ sb.WriteString(string(ieh.Props))
+ ieh.VersionID = fmt.Sprintf("%x", md5.Sum([]byte(sb.String())))
+}
+
+type CopyUploadProps struct {
+ UploadID int64 `json:"uploadId"`
+}
diff --git a/package-lock.json b/package-lock.json
index 2068fd3..eadf529 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,8 @@
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0",
- "feather-icons": "^4.29.2"
+ "feather-icons": "^4.29.2",
+ "handlebars": "^4.7.8"
},
"devDependencies": {
"esbuild": "0.27.3"
@@ -892,6 +893,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/handlebars": {
+ "version": "4.7.8",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
+ "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.2",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -958,6 +980,21 @@
"node": ">=0.10.0"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "license": "MIT"
+ },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -1395,6 +1432,15 @@
"node": ">=14.0.0"
}
},
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1462,12 +1508,31 @@
"license": "0BSD",
"peer": true
},
+ "node_modules/uglify-js": {
+ "version": "3.19.3",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
+ "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"license": "MIT",
"peer": true
+ },
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index 5a786ac..3455630 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0",
- "feather-icons": "^4.29.2"
+ "feather-icons": "^4.29.2",
+ "handlebars": "^4.7.8"
}
}
diff --git a/providers/db/gen/sqlgen/categories.sql.go b/providers/db/gen/sqlgen/categories.sql.go
index d5bc40d..95a26e5 100644
--- a/providers/db/gen/sqlgen/categories.sql.go
+++ b/providers/db/gen/sqlgen/categories.sql.go
@@ -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
diff --git a/providers/db/gen/sqlgen/db.go b/providers/db/gen/sqlgen/db.go
index 8eab959..7d9d9e7 100644
--- a/providers/db/gen/sqlgen/db.go
+++ b/providers/db/gen/sqlgen/db.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.30.0
package sqlgen
diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go
index 3df1193..348c1ab 100644
--- a/providers/db/gen/sqlgen/models.go
+++ b/providers/db/gen/sqlgen/models.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.30.0
package sqlgen
diff --git a/providers/db/gen/sqlgen/pages.sql.go b/providers/db/gen/sqlgen/pages.sql.go
index 1d53291..7dd5105 100644
--- a/providers/db/gen/sqlgen/pages.sql.go
+++ b/providers/db/gen/sqlgen/pages.sql.go
@@ -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
diff --git a/providers/db/gen/sqlgen/pending_uploads.sql.go b/providers/db/gen/sqlgen/pending_uploads.sql.go
index 63eeb60..a831bbe 100644
--- a/providers/db/gen/sqlgen/pending_uploads.sql.go
+++ b/providers/db/gen/sqlgen/pending_uploads.sql.go
@@ -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
diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go
index ef3d170..129a49a 100644
--- a/providers/db/gen/sqlgen/posts.sql.go
+++ b/providers/db/gen/sqlgen/posts.sql.go
@@ -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
diff --git a/providers/db/gen/sqlgen/pubtargets.sql.go b/providers/db/gen/sqlgen/pubtargets.sql.go
index 69c09df..cd5cfa6 100644
--- a/providers/db/gen/sqlgen/pubtargets.sql.go
+++ b/providers/db/gen/sqlgen/pubtargets.sql.go
@@ -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
diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go
index 80ccbc0..797eaad 100644
--- a/providers/db/gen/sqlgen/sites.sql.go
+++ b/providers/db/gen/sqlgen/sites.sql.go
@@ -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
diff --git a/providers/db/gen/sqlgen/uploads.sql.go b/providers/db/gen/sqlgen/uploads.sql.go
index 189de2d..7ad3828 100644
--- a/providers/db/gen/sqlgen/uploads.sql.go
+++ b/providers/db/gen/sqlgen/uploads.sql.go
@@ -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
+}
diff --git a/providers/db/gen/sqlgen/users.sql.go b/providers/db/gen/sqlgen/users.sql.go
index 6007589..a70a3bf 100644
--- a/providers/db/gen/sqlgen/users.sql.go
+++ b/providers/db/gen/sqlgen/users.sql.go
@@ -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
diff --git a/providers/db/uploads.go b/providers/db/uploads.go
index 006b7cc..b3033ab 100644
--- a/providers/db/uploads.go
+++ b/providers/db/uploads.go
@@ -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)
}
diff --git a/providers/uploadfiles/provider.go b/providers/uploadfiles/provider.go
index 2eb84e4..610a6f9 100644
--- a/providers/uploadfiles/provider.go
+++ b/providers/uploadfiles/provider.go
@@ -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)
diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go
new file mode 100644
index 0000000..ec84199
--- /dev/null
+++ b/services/imgedit/processing.go
@@ -0,0 +1,171 @@
+package imgedit
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "image"
+ "image/color"
+ "os"
+ "path/filepath"
+
+ "github.com/disintegration/imaging"
+ "lmika.dev/lmika/weiro/models"
+)
+
+type imageProcessor struct {
+ newParams func() any
+ processImage func(ctx context.Context, srcImg image.Image, params any) (image.Image, error)
+}
+
+type shadowProcessorArgs struct {
+ Color string `json:"color"`
+ OffsetY int `json:"offset_y,string"`
+}
+
+var processors = map[string]imageProcessor{
+ "shadow": {
+ newParams: func() any {
+ return &shadowProcessorArgs{
+ Color: "#000000",
+ OffsetY: 0,
+ }
+ },
+ processImage: func(ctx context.Context, srcImg image.Image, params any) (image.Image, error) {
+ p := params.(*shadowProcessorArgs)
+
+ shadowColor, err := parseHexColor(p.Color)
+ if err != nil {
+ return nil, fmt.Errorf("invalid shadow color: %w", err)
+ }
+
+ shadow := makeBoxShadow(srcImg, shadowColor, 4, 10, p.OffsetY)
+ composit := imaging.OverlayCenter(shadow, srcImg, 1.0)
+ return composit, nil
+ },
+ },
+}
+
+func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSession) (imageSource, error) {
+ var img imageSource
+
+ for _, p := range session.Processors {
+ // Check if there's currently a cached image of this processor
+ cachedImageFile := filepath.Join(s.scratchDir, session.GUID, fmt.Sprintf("%v.%v", p.VersionID, session.ImageExt))
+ if s, err := os.Stat(cachedImageFile); err == nil && !s.IsDir() {
+ img = fileImageSource(cachedImageFile)
+ continue
+ }
+
+ // Need to process the image
+ var srcImg image.Image
+ if img != nil {
+ var err error
+ srcImg, err = img.image()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ resImg, err := s.processImage(ctx, srcImg, p)
+ if err != nil {
+ return nil, err
+ }
+
+ // Cache the processed image
+ if err := imaging.Save(resImg, cachedImageFile); err != nil {
+ return nil, err
+ }
+ img = imageImageSource{resImg}
+ }
+
+ return img, nil
+}
+
+func (s *Service) processImage(ctx context.Context, srcImg image.Image, processor models.ImageEditProcessor) (image.Image, error) {
+ switch processor.Type {
+ case "copy-upload":
+ var p models.CopyUploadProps
+ if err := json.Unmarshal(processor.Props, &p); err != nil {
+ return nil, err
+ }
+
+ _, rc, err := s.uploadService.OpenUpload(ctx, p.UploadID)
+ if err != nil {
+ return nil, err
+ }
+
+ f, err := rc()
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ return imaging.Decode(f)
+ }
+
+ proc, ok := processors[processor.Type]
+ if !ok {
+ return nil, fmt.Errorf("unknown processor type: %v", processor.Type)
+ }
+
+ paramType := proc.newParams()
+ if err := json.Unmarshal(processor.Props, paramType); err != nil {
+ return nil, err
+ }
+ return proc.processImage(ctx, srcImg, paramType)
+}
+
+type imageSource interface {
+ image() (image.Image, error)
+}
+
+type fileImageSource string
+
+func (f fileImageSource) image() (image.Image, error) {
+ return imaging.Open(string(f))
+}
+
+type imageImageSource struct {
+ img image.Image
+}
+
+func (i imageImageSource) image() (image.Image, error) {
+ return i.img, nil
+}
+
+func parseHexColor(s string) (color.Color, error) {
+ // Remove leading hash if present
+ if len(s) > 0 && s[0] == '#' {
+ s = s[1:]
+ }
+
+ // Parse based on length
+ var r, g, b, a uint8
+ switch len(s) {
+ case 6:
+ // RGB format
+ var rgb uint32
+ if _, err := fmt.Sscanf(s, "%06x", &rgb); err != nil {
+ return nil, fmt.Errorf("invalid hex color format: %w", err)
+ }
+ r = uint8((rgb >> 16) & 0xFF)
+ g = uint8((rgb >> 8) & 0xFF)
+ b = uint8(rgb & 0xFF)
+ a = 0xFF
+ case 8:
+ // RGBA format
+ var rgba uint32
+ if _, err := fmt.Sscanf(s, "%08x", &rgba); err != nil {
+ return nil, fmt.Errorf("invalid hex color format: %w", err)
+ }
+ r = uint8((rgba >> 24) & 0xFF)
+ g = uint8((rgba >> 16) & 0xFF)
+ b = uint8((rgba >> 8) & 0xFF)
+ a = uint8(rgba & 0xFF)
+ default:
+ return nil, fmt.Errorf("invalid hex color length: expected 6 or 8 characters, got %d", len(s))
+ }
+
+ return color.RGBA{R: r, G: g, B: b, A: a}, nil
+}
diff --git a/services/imgedit/service.go b/services/imgedit/service.go
new file mode 100644
index 0000000..926633c
--- /dev/null
+++ b/services/imgedit/service.go
@@ -0,0 +1,266 @@
+package imgedit
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "time"
+
+ "lmika.dev/lmika/weiro/models"
+ "lmika.dev/lmika/weiro/services/uploads"
+ "lmika.dev/pkg/modash/moslice"
+)
+
+type Service struct {
+ scratchDir string
+ uploadService *uploads.Service
+ sessionStore *sessionStore
+}
+
+func New(
+ uploadService *uploads.Service,
+ scratchDir string,
+) *Service {
+ return &Service{
+ scratchDir: scratchDir,
+ uploadService: uploadService,
+ sessionStore: &sessionStore{baseDir: scratchDir},
+ }
+}
+
+func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (*models.ImageEditSession, error) {
+ site, user, err := s.fetchSiteAndUser(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ upload, _, err := s.uploadService.OpenUpload(ctx, baseUploadID)
+ if err != nil {
+ return nil, err
+ }
+
+ var ext string
+ switch upload.MIMEType {
+ case "image/jpeg":
+ ext = "jpg"
+ case "image/png":
+ ext = "png"
+ default:
+ return nil, models.UnsupportedImageFormat
+ }
+
+ newSession := models.ImageEditSession{
+ GUID: models.NewNanoID(),
+ SiteID: site.ID,
+ UserID: user.ID,
+ BaseUploadID: baseUploadID,
+ ImageExt: ext,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ Processors: []models.ImageEditProcessor{
+ {
+ ID: models.NewNanoID(),
+ Type: "copy-upload",
+ Props: mustToJSON(models.CopyUploadProps{UploadID: baseUploadID}),
+ },
+ },
+ }
+
+ newSession.RecalcVersionIDs()
+ if err := s.sessionStore.save(&newSession); err != nil {
+ return nil, err
+ }
+
+ if _, err := s.reprocess(ctx, &newSession); err != nil {
+ return nil, err
+ }
+
+ return &newSession, nil
+}
+
+func (s *Service) LoadImageVersion(ctx context.Context, sessionID string, versionID string) (mimeType string, rw func() (io.ReadCloser, error), err error) {
+ session, err := s.loadAndVerifySession(ctx, sessionID)
+ if err != nil {
+ return "", nil, err
+ }
+
+ return s.sessionStore.getImage(session, versionID+"."+session.ImageExt)
+}
+
+type AddProcessorReq struct {
+ Type string `json:"type"`
+}
+
+func (s *Service) AddProcessor(ctx context.Context, sessionID string, req AddProcessorReq) (*models.ImageEditSession, error) {
+ session, err := s.loadAndVerifySession(ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+
+ proc, ok := processors[req.Type]
+ if !ok {
+ return nil, fmt.Errorf("unknown processor type: %v", req.Type)
+ }
+
+ paramType := proc.newParams()
+ paramBytes, err := json.Marshal(paramType)
+ if err != nil {
+ return nil, err
+ }
+
+ session.Processors = append(session.Processors, models.ImageEditProcessor{
+ ID: models.NewNanoID(),
+ Type: req.Type,
+ Props: paramBytes,
+ })
+
+ session.RecalcVersionIDs()
+ if err := s.sessionStore.save(session); err != nil {
+ return nil, err
+ }
+
+ if _, err := s.reprocess(ctx, session); err != nil {
+ return nil, err
+ }
+
+ return session, nil
+}
+
+func (s *Service) DeleteProcessor(ctx context.Context, sessionID, processorID string) (*models.ImageEditSession, error) {
+ session, err := s.loadAndVerifySession(ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+
+ session.Processors = moslice.Filter(session.Processors, func(p models.ImageEditProcessor) bool { return p.ID != processorID })
+ session.RecalcVersionIDs()
+ if err := s.sessionStore.save(session); err != nil {
+ return nil, err
+ }
+
+ if _, err := s.reprocess(ctx, session); err != nil {
+ return nil, err
+ }
+
+ return session, nil
+}
+
+type UpdateProcessorReq struct {
+ ID string `json:"id"`
+ Props json.RawMessage `json:"props"`
+}
+
+func (s *Service) UpdateProcessor(ctx context.Context, sessionID string, req UpdateProcessorReq) (*models.ImageEditSession, error) {
+ session, err := s.loadAndVerifySession(ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+
+ for i, p := range session.Processors {
+ if p.ID == req.ID {
+ session.Processors[i].Props = req.Props
+ break
+ }
+ }
+
+ session.RecalcVersionIDs()
+ if err := s.sessionStore.save(session); err != nil {
+ return nil, err
+ }
+ if _, err := s.reprocess(ctx, session); err != nil {
+ return nil, err
+ }
+
+ 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 {
+ return nil, err
+ }
+
+ session, err := s.sessionStore.get(sessionID)
+ if err != nil {
+ return nil, err
+ } else if session.SiteID != site.ID || session.UserID != user.ID {
+ return nil, models.PermissionError
+ }
+ return session, nil
+}
+
+func (s *Service) fetchSiteAndUser(ctx context.Context) (models.Site, models.User, error) {
+ user, ok := models.GetUser(ctx)
+ if !ok {
+ return models.Site{}, models.User{}, models.UserRequiredError
+ }
+
+ site, ok := models.GetSite(ctx)
+ if !ok {
+ return models.Site{}, models.User{}, models.SiteRequiredError
+ }
+
+ if site.OwnerID != user.ID {
+ return models.Site{}, models.User{}, models.PermissionError
+ }
+
+ return site, user, nil
+}
+
+func mustToJSON(a any) json.RawMessage {
+ b, _ := json.Marshal(a)
+ return b
+}
diff --git a/services/imgedit/shadow.go b/services/imgedit/shadow.go
new file mode 100644
index 0000000..4a308d0
--- /dev/null
+++ b/services/imgedit/shadow.go
@@ -0,0 +1,35 @@
+package imgedit
+
+import (
+ "image"
+ "image/color"
+
+ "github.com/disintegration/imaging"
+)
+
+func makeBoxShadow(maskImg image.Image, shadowColor color.Color, sigma float64, shadowMargin, offsetY int) image.Image {
+ w, h := maskImg.Bounds().Dx(), maskImg.Bounds().Dy()
+ cr, cg, cb, _ := shadowColor.RGBA()
+ cr8, cg8, cb8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8)
+
+ // New box image
+ backing := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY))
+ newImg := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY))
+ for x := 0; x < w+shadowMargin*2; x++ {
+ for y := 0; y < h+shadowMargin*2; y++ {
+ var c = color.NRGBA{R: 255, G: 255, B: 255, A: 0}
+ if x >= shadowMargin-4 && y >= shadowMargin-4 && x <= w+shadowMargin+4 && y <= h+shadowMargin+4 {
+ _, _, _, a := maskImg.At(x-shadowMargin, y-shadowMargin).RGBA()
+ c = color.NRGBA{R: cr8, G: cg8, B: cb8, A: uint8(a >> 8)}
+ }
+ backing.SetNRGBA(x, y, color.NRGBA{R: 255, G: 255, B: 255, A: 0})
+ newImg.SetNRGBA(x, y+offsetY, c)
+ }
+ }
+
+ // Blur
+ blurredImage := imaging.Blur(newImg, sigma)
+ backing = imaging.OverlayCenter(backing, blurredImage, 0.6)
+
+ return backing
+}
diff --git a/services/imgedit/store.go b/services/imgedit/store.go
new file mode 100644
index 0000000..df3403a
--- /dev/null
+++ b/services/imgedit/store.go
@@ -0,0 +1,70 @@
+package imgedit
+
+import (
+ "encoding/json"
+ "io"
+ "os"
+ "path/filepath"
+
+ "lmika.dev/lmika/weiro/models"
+)
+
+type sessionStore struct {
+ baseDir string
+}
+
+func (ss *sessionStore) save(newSession *models.ImageEditSession) error {
+ sessionMeta, err := json.Marshal(newSession)
+ if err != nil {
+ return err
+ }
+
+ if err := os.MkdirAll(filepath.Join(ss.baseDir, newSession.GUID), 0755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(ss.baseDir, newSession.GUID, "session.json"), sessionMeta, 0644); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (ss *sessionStore) get(guid string) (*models.ImageEditSession, error) {
+ sessionDataBts, err := os.ReadFile(filepath.Join(ss.baseDir, guid, "session.json"))
+ if err != nil {
+ return nil, err
+ }
+
+ sessionData := models.ImageEditSession{}
+ if err := json.Unmarshal(sessionDataBts, &sessionData); err != nil {
+ return nil, err
+ }
+
+ 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 {
+ return "", nil, err
+ } else if s.IsDir() {
+ return "", nil, os.ErrNotExist
+ }
+
+ var mimeType string
+ switch filepath.Ext(imageFilename) {
+ case ".jpg", ".jpeg":
+ mimeType = "image/jpeg"
+ case ".png":
+ mimeType = "image/png"
+ default:
+ return "", nil, models.UnsupportedImageFormat
+ }
+
+ return mimeType, func() (io.ReadCloser, error) {
+ return os.Open(fullPath)
+ }, nil
+}
diff --git a/services/services.go b/services/services.go
index 852dea3..ab1a4ca 100644
--- a/services/services.go
+++ b/services/services.go
@@ -8,6 +8,7 @@ import (
"lmika.dev/lmika/weiro/providers/uploadfiles"
"lmika.dev/lmika/weiro/services/auth"
"lmika.dev/lmika/weiro/services/categories"
+ "lmika.dev/lmika/weiro/services/imgedit"
"lmika.dev/lmika/weiro/services/pages"
"lmika.dev/lmika/weiro/services/posts"
"lmika.dev/lmika/weiro/services/publisher"
@@ -23,6 +24,7 @@ type Services struct {
Posts *posts.Service
Sites *sites.Service
Uploads *uploads.Service
+ ImageEdit *imgedit.Service
Categories *categories.Service
Pages *pages.Service
}
@@ -41,6 +43,7 @@ func New(cfg config.Config) (*Services, error) {
postService := posts.New(dbp, publisherQueue)
siteService := sites.New(dbp)
uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
+ imageEditService := imgedit.New(uploadService, filepath.Join(cfg.ScratchDir, "imageedit"))
categoriesService := categories.New(dbp, publisherQueue)
pagesService := pages.New(dbp, publisherQueue)
@@ -52,6 +55,7 @@ func New(cfg config.Config) (*Services, error) {
Posts: postService,
Sites: siteService,
Uploads: uploadService,
+ ImageEdit: imageEditService,
Categories: categoriesService,
Pages: pagesService,
}, nil
diff --git a/services/uploads/manage.go b/services/uploads/manage.go
index 32debac..9cb24ea 100644
--- a/services/uploads/manage.go
+++ b/services/uploads/manage.go
@@ -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 {
diff --git a/sql/queries/uploads.sql b/sql/queries/uploads.sql
index fc8b82d..f661591 100644
--- a/sql/queries/uploads.sql
+++ b/sql/queries/uploads.sql
@@ -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 = ?;
\ No newline at end of file
diff --git a/views/posts/edit.html b/views/posts/edit.html
index b9f5ea7..fbb94fa 100644
--- a/views/posts/edit.html
+++ b/views/posts/edit.html
@@ -1,5 +1,5 @@
{{ $isPublished := ne .post.State 1 }}
-
+
\ No newline at end of file
diff --git a/views/uploads/show.html b/views/uploads/show.html
index 087c10f..7b42a38 100644
--- a/views/uploads/show.html
+++ b/views/uploads/show.html
@@ -5,7 +5,10 @@
data-show-upload-site-id-value="{{ .upload.Upload.SiteID }}"
data-show-upload-upload-id-value="{{ .upload.Upload.ID }}">
-
+
+ Edit
+
+