Compare commits
17 commits
feature/pa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21aeadd56 | ||
|
|
deca23b599 | ||
|
|
9b20665d11 | ||
|
|
023574aac6 | ||
|
|
5ed00c97d6 | ||
|
|
98828a4849 | ||
|
|
c8a276b248 | ||
|
|
f9a65c8ca9 | ||
|
|
488942db2e | ||
|
|
2d42a0ef90 | ||
|
|
599c72d465 | ||
|
|
036b683eab | ||
|
|
18f9f49c0a | ||
|
|
8c371ccae9 | ||
|
|
d5bfdcbb06 | ||
|
|
d80aacc180 | ||
|
|
cc0da8d668 |
|
|
@ -10,6 +10,15 @@ $container-max-widths: (
|
||||||
|
|
||||||
@import "bootstrap/scss/bootstrap.scss";
|
@import "bootstrap/scss/bootstrap.scss";
|
||||||
|
|
||||||
|
// Navbar
|
||||||
|
|
||||||
|
.navbar-site-visit {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 2em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
// Post list
|
// Post list
|
||||||
|
|
||||||
.postlist .post img {
|
.postlist .post img {
|
||||||
|
|
@ -22,19 +31,24 @@ $container-max-widths: (
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post form
|
// Large editor
|
||||||
|
//
|
||||||
|
// Used for edit canvases which take up the entire window
|
||||||
|
|
||||||
// Post edit page styling
|
.large-editor {
|
||||||
.post-edit-page {
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-edit-page main {
|
.large-editor main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post form
|
||||||
|
|
||||||
|
// Post edit page styling
|
||||||
|
|
||||||
.post-edit-page .post-form {
|
.post-edit-page .post-form {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
233
assets/js/controllers/edit_upload.js
Normal file
233
assets/js/controllers/edit_upload.js
Normal file
|
|
@ -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(`
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>{{name}}</span>
|
||||||
|
<a href="#" class="float-end"
|
||||||
|
data-action="edit-upload#removeProcessor"
|
||||||
|
data-edit-upload-id-param="{{id}}"
|
||||||
|
><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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const processorUIs = {
|
||||||
|
"shadow": {
|
||||||
|
label: "Shadow",
|
||||||
|
template: Handlebars.compile(`
|
||||||
|
<div class="row mb-3 align-items-center">
|
||||||
|
<label for="{{id}}_color" class="col-sm col-form-label">Colour</label>
|
||||||
|
<div class="col-sm">
|
||||||
|
<input name="color" class="form-control" id="{{id}}_color" type="color" value="{{props.color}}" {{{submit_on id 'change'}}}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for="{{id}}_offset_y" class="col-sm col-form-label">Offset Y</label>
|
||||||
|
<div class="col-sm">
|
||||||
|
<input name="offset_y" class="form-control" id="{{id}}_{{props.color}}" type="number" value="{{props.offset_y}}" {{{submit_on id 'blur'}}}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
"resize": {
|
||||||
|
label: "Resize",
|
||||||
|
template: Handlebars.compile(`
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{id}}_width" class="form-label">Width</label>
|
||||||
|
<input name="width" class="form-control" id="{{id}}_width">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{id}}_height" class="form-label">Height</label>
|
||||||
|
<input name="width" class="form-control" id="{{id}}_width">
|
||||||
|
</div>
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,16 @@ export default class PosteditController extends Controller {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData(this.element);
|
const formData = new FormData(this.element);
|
||||||
let data = Object.fromEntries(formData.entries());
|
let data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
|
// Special handling for categories
|
||||||
|
let categoryIDs = [];
|
||||||
|
for (let i of formData.entries()) {
|
||||||
|
if (i[0] === "category_ids") {
|
||||||
|
categoryIDs.push(parseInt(i[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data["category_ids"] = categoryIDs;
|
||||||
data = {...data, action: action || 'save'};
|
data = {...data, action: action || 'save'};
|
||||||
|
|
||||||
const response = await fetch(this.element.getAttribute("action"), {
|
const response = await fetch(this.element.getAttribute("action"), {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import feather from "feather-icons/dist/feather.js";
|
||||||
import { Application } from "@hotwired/stimulus";
|
import { Application } from "@hotwired/stimulus";
|
||||||
|
|
||||||
import ToastController from "./controllers/toast";
|
import ToastController from "./controllers/toast";
|
||||||
|
|
@ -7,6 +8,7 @@ import LogoutController from "./controllers/logout";
|
||||||
import FirstRunController from "./controllers/firstrun";
|
import FirstRunController from "./controllers/firstrun";
|
||||||
import UploadController from "./controllers/upload";
|
import UploadController from "./controllers/upload";
|
||||||
import ShowUploadController from "./controllers/show_upload";
|
import ShowUploadController from "./controllers/show_upload";
|
||||||
|
import EditUploadController from "./controllers/edit_upload";
|
||||||
import PagelistController from "./controllers/pagelist";
|
import PagelistController from "./controllers/pagelist";
|
||||||
|
|
||||||
window.Stimulus = Application.start()
|
window.Stimulus = Application.start()
|
||||||
|
|
@ -17,4 +19,7 @@ Stimulus.register("logout", LogoutController);
|
||||||
Stimulus.register("first-run", FirstRunController);
|
Stimulus.register("first-run", FirstRunController);
|
||||||
Stimulus.register("upload", UploadController);
|
Stimulus.register("upload", UploadController);
|
||||||
Stimulus.register("show-upload", ShowUploadController);
|
Stimulus.register("show-upload", ShowUploadController);
|
||||||
|
Stimulus.register("edit-upload", EditUploadController);
|
||||||
Stimulus.register("pagelist", PagelistController);
|
Stimulus.register("pagelist", PagelistController);
|
||||||
|
|
||||||
|
feather.replace();
|
||||||
|
|
@ -111,6 +111,7 @@ Starting weiro without any arguments will start the server.
|
||||||
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
|
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
|
||||||
ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories}
|
ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories}
|
||||||
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
|
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
|
||||||
|
ieh := handlers.ImageEditHandlers{ImageEditService: svcs.ImageEdit}
|
||||||
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
|
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
|
||||||
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
|
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
|
||||||
pgh := handlers.PagesHandler{PageService: svcs.Pages}
|
pgh := handlers.PagesHandler{PageService: svcs.Pages}
|
||||||
|
|
@ -119,7 +120,17 @@ Starting weiro without any arguments will start the server.
|
||||||
app.Post("/login", lh.DoLogin)
|
app.Post("/login", lh.DoLogin)
|
||||||
app.Post("/logout", lh.Logout)
|
app.Post("/logout", lh.Logout)
|
||||||
|
|
||||||
siteGroup := app.Group("/sites/:siteID", middleware.LogErrors(), middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites))
|
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
|
||||||
|
app.Get("/first-run", ih.FirstRun)
|
||||||
|
app.Post("/first-run", ih.FirstRunSubmit)
|
||||||
|
|
||||||
|
app.Get("/static/*", static.New("./static"))
|
||||||
|
|
||||||
|
app.Use(middleware.LogErrors(), middleware.RequireUser(svcs.Auth))
|
||||||
|
|
||||||
|
app.Get("/sites/new", ssh.New)
|
||||||
|
app.Post("/sites", ssh.Create)
|
||||||
|
siteGroup := app.Group("/sites/:siteID", middleware.RequiresSite(svcs.Sites))
|
||||||
|
|
||||||
siteGroup.Get("/posts", ph.Index)
|
siteGroup.Get("/posts", ph.Index)
|
||||||
siteGroup.Get("/posts/new", ph.New)
|
siteGroup.Get("/posts/new", ph.New)
|
||||||
|
|
@ -139,6 +150,14 @@ Starting weiro without any arguments will start the server.
|
||||||
siteGroup.Post("/uploads/pending/:guid", uh.UploadPart)
|
siteGroup.Post("/uploads/pending/:guid", uh.UploadPart)
|
||||||
siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete)
|
siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete)
|
||||||
siteGroup.Delete("/uploads/:uploadID", uh.Delete)
|
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.Get("/settings", ssh.General)
|
||||||
siteGroup.Post("/settings", ssh.UpdateGeneral)
|
siteGroup.Post("/settings", ssh.UpdateGeneral)
|
||||||
|
|
@ -158,12 +177,6 @@ Starting weiro without any arguments will start the server.
|
||||||
siteGroup.Post("/pages/:pageID", pgh.Update)
|
siteGroup.Post("/pages/:pageID", pgh.Update)
|
||||||
siteGroup.Post("/pages/:pageID/delete", pgh.Delete)
|
siteGroup.Post("/pages/:pageID/delete", pgh.Delete)
|
||||||
|
|
||||||
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
|
|
||||||
app.Get("/first-run", ih.FirstRun)
|
|
||||||
app.Post("/first-run", ih.FirstRunSubmit)
|
|
||||||
|
|
||||||
app.Get("/static/*", static.New("./static"))
|
|
||||||
|
|
||||||
if err := app.Listen(":3000"); err != nil {
|
if err := app.Listen(":3000"); err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
165
handlers/imageedit.go
Normal file
165
handlers/imageedit.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"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)
|
site, err := h.SiteService.BestSite(c.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,8 @@ func (lh *LoginHandler) Logout(c fiber.Ctx) error {
|
||||||
|
|
||||||
func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
|
func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
Username string `form:"username"`
|
Username string `form:"username"`
|
||||||
Password string `form:"password"`
|
Password string `form:"password"`
|
||||||
LoginChallenge string `form:"_login_challenge"`
|
|
||||||
}
|
}
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body")
|
return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body")
|
||||||
|
|
@ -51,11 +50,6 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
|
||||||
|
|
||||||
sess := session.FromContext(c)
|
sess := session.FromContext(c)
|
||||||
|
|
||||||
challenge, _ := sess.Get("_login_challenge").(string)
|
|
||||||
if challenge != req.LoginChallenge {
|
|
||||||
return c.Redirect().To("/login")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password)
|
user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
func LogErrors() func(c fiber.Ctx) error {
|
func LogErrors() func(c fiber.Ctx) error {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
if err := c.Next(); err != nil {
|
if err := c.Next(); err != nil {
|
||||||
log.Printf("error: %v\n", err)
|
log.Printf("%v: error: %v\n", c.Path(), err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/session"
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
"lmika.dev/lmika/weiro/providers/db"
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
"lmika.dev/lmika/weiro/services/sites"
|
"lmika.dev/lmika/weiro/services/sites"
|
||||||
|
|
@ -32,9 +33,22 @@ func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Locals("site", site)
|
c.Locals("site", site)
|
||||||
c.SetContext(models.WithSite(c.Context(), site))
|
c.SetContext(models.WithSite(c.Context(), site))
|
||||||
|
|
||||||
|
sitesOwnedByUser, err := sites.ListSites(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ func (ph PostsHandler) New(c fiber.Ctx) error {
|
||||||
"post": p,
|
"post": p,
|
||||||
"categories": cats,
|
"categories": cats,
|
||||||
"selectedCategories": map[int64]bool{},
|
"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,
|
"post": post,
|
||||||
"categories": cats,
|
"categories": cats,
|
||||||
"selectedCategories": selectedCategories,
|
"selectedCategories": selectedCategories,
|
||||||
"bodyClass": "post-edit-page",
|
"bodyClass": "large-editor",
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,28 @@ type SiteSettingsHandler struct {
|
||||||
SiteService *sites.Service
|
SiteService *sites.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SiteSettingsHandler) General(ctx fiber.Ctx) error {
|
func (s *SiteSettingsHandler) New(c fiber.Ctx) error {
|
||||||
site := ctx.Locals("site").(models.Site)
|
return c.Render("sitesettings/new", fiber.Map{}, "layouts/bare_with_scripts")
|
||||||
|
}
|
||||||
|
|
||||||
return ctx.Render("sitesettings/general", fiber.Map{
|
func (s *SiteSettingsHandler) Create(c fiber.Ctx) error {
|
||||||
|
var params sites.CreateSiteParams
|
||||||
|
if err := c.Bind().Body(¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newSite, err := s.SiteService.CreateSite(c.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", newSite.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SiteSettingsHandler) General(c fiber.Ctx) error {
|
||||||
|
site := c.Locals("site").(models.Site)
|
||||||
|
|
||||||
|
return c.Render("sitesettings/general", fiber.Map{
|
||||||
"site": site,
|
"site": site,
|
||||||
"tzones": sites.ListZones(),
|
"tzones": sites.ListZones(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -162,3 +162,24 @@ func (uh UploadsHandler) UploadComplete(c fiber.Ctx) error {
|
||||||
|
|
||||||
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{})
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@
|
||||||
<header>
|
<header>
|
||||||
<h1>{{ .Site.Title }}</h1>
|
<h1>{{ .Site.Title }}</h1>
|
||||||
<p>{{ .Site.Tagline }}</p>
|
<p>{{ .Site.Tagline }}</p>
|
||||||
|
{{ if .Site.NavItems }}
|
||||||
|
<nav>
|
||||||
|
{{ range .Site.NavItems }}
|
||||||
|
{{ if .ShowInNav }}<a href="{{ url_abs .Slug }}">{{ .Title }}</a>{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</nav>
|
||||||
|
{{ end }}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,4 @@ var NotFoundError = errors.New("not found")
|
||||||
var SiteRequiredError = errors.New("site required")
|
var SiteRequiredError = errors.New("site required")
|
||||||
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
|
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 SlugConflictError = errors.New("a record with this slug already exists")
|
||||||
|
var UnsupportedImageFormat = errors.New("unsupported image format")
|
||||||
|
|
|
||||||
62
models/imgedit.go
Normal file
62
models/imgedit.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"iter"
|
"iter"
|
||||||
|
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/pkg/modash/moslice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Site struct {
|
type Site struct {
|
||||||
|
|
@ -20,3 +21,7 @@ type Site struct {
|
||||||
CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error)
|
CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error)
|
||||||
Pages []*models.Page
|
Pages []*models.Page
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s Site) NavItems() []*models.Page {
|
||||||
|
return moslice.Filter(s.Pages, func(p *models.Page) bool { return p.ShowInNav })
|
||||||
|
}
|
||||||
|
|
|
||||||
101
package-lock.json
generated
101
package-lock.json
generated
|
|
@ -7,7 +7,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"esbuild-sass-plugin": "^3.6.0"
|
"esbuild-sass-plugin": "^3.6.0",
|
||||||
|
"feather-icons": "^4.29.2",
|
||||||
|
"handlebars": "^4.7.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "0.27.3"
|
"esbuild": "0.27.3"
|
||||||
|
|
@ -783,6 +785,12 @@
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classnames": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/colorjs.io": {
|
"node_modules/colorjs.io": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
|
||||||
|
|
@ -790,6 +798,17 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||||
|
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
|
@ -855,6 +874,16 @@
|
||||||
"sass-embedded": "^1.97.2"
|
"sass-embedded": "^1.97.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/feather-icons": {
|
||||||
|
"version": "4.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz",
|
||||||
|
"integrity": "sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"classnames": "^2.2.5",
|
||||||
|
"core-js": "^3.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|
@ -864,6 +893,27 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
|
@ -887,9 +937,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
|
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-core-module": {
|
"node_modules/is-core-module": {
|
||||||
|
|
@ -930,6 +980,21 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
|
@ -1367,6 +1432,15 @@
|
||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
@ -1434,12 +1508,31 @@
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"peer": true
|
"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": {
|
"node_modules/varint": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
|
||||||
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
|
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"esbuild-sass-plugin": "^3.6.0"
|
"esbuild-sass-plugin": "^3.6.0",
|
||||||
|
"feather-icons": "^4.29.2",
|
||||||
|
"handlebars": "^4.7.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,8 @@ func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([
|
||||||
return cats, nil
|
return cats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) {
|
func (db *Provider) SelectPublishedPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) {
|
||||||
rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{
|
rows, err := db.queries.SelectPublishedPostsOfCategory(ctx, sqlgen.SelectPublishedPostsOfCategoryParams{
|
||||||
CategoryID: categoryID,
|
CategoryID: categoryID,
|
||||||
Limit: pp.Limit,
|
Limit: pp.Limit,
|
||||||
Offset: pp.Offset,
|
Offset: pp.Offset,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
// source: categories.sql
|
// source: categories.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
@ -227,7 +227,7 @@ func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCat
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many
|
const selectPublishedPostsOfCategory = `-- name: SelectPublishedPostsOfCategory :many
|
||||||
SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at FROM posts p
|
SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at FROM posts p
|
||||||
INNER JOIN post_categories pc ON pc.post_id = p.id
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||||||
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
||||||
|
|
@ -235,14 +235,14 @@ ORDER BY p.published_at DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type SelectPostsOfCategoryParams struct {
|
type SelectPublishedPostsOfCategoryParams struct {
|
||||||
CategoryID int64
|
CategoryID int64
|
||||||
Limit int64
|
Limit int64
|
||||||
Offset int64
|
Offset int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) {
|
func (q *Queries) SelectPublishedPostsOfCategory(ctx context.Context, arg SelectPublishedPostsOfCategoryParams) ([]Post, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset)
|
rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
// source: pages.sql
|
// source: pages.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
// source: pending_uploads.sql
|
// source: pending_uploads.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
// source: posts.sql
|
// source: posts.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
@ -200,6 +200,54 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSitePa
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectPublishedPostsOfSite = `-- name: SelectPublishedPostsOfSite :many
|
||||||
|
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at
|
||||||
|
FROM posts
|
||||||
|
WHERE site_id = ?1 AND state = 0 AND deleted_at = 0
|
||||||
|
ORDER BY published_at DESC LIMIT ?3 OFFSET ?2
|
||||||
|
`
|
||||||
|
|
||||||
|
type SelectPublishedPostsOfSiteParams struct {
|
||||||
|
SiteID int64
|
||||||
|
Offset int64
|
||||||
|
Limit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SelectPublishedPostsOfSite(ctx context.Context, arg SelectPublishedPostsOfSiteParams) ([]Post, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfSite, arg.SiteID, arg.Offset, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Post
|
||||||
|
for rows.Next() {
|
||||||
|
var i Post
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.State,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Title,
|
||||||
|
&i.Body,
|
||||||
|
&i.Slug,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.PublishedAt,
|
||||||
|
&i.DeletedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const softDeletePost = `-- name: SoftDeletePost :exec
|
const softDeletePost = `-- name: SoftDeletePost :exec
|
||||||
UPDATE posts SET deleted_at = ? WHERE id = ?
|
UPDATE posts SET deleted_at = ? WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
// source: pubtargets.sql
|
// source: pubtargets.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
// source: sites.sql
|
// source: sites.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
// source: uploads.sql
|
// source: uploads.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
@ -18,7 +18,7 @@ func (q *Queries) DeleteUpload(ctx context.Context, id int64) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertUpload = `-- name: InsertUpload :exec
|
const insertUpload = `-- name: InsertUpload :one
|
||||||
INSERT INTO uploads (
|
INSERT INTO uploads (
|
||||||
site_id,
|
site_id,
|
||||||
guid,
|
guid,
|
||||||
|
|
@ -43,8 +43,8 @@ type InsertUploadParams struct {
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) error {
|
func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) (int64, error) {
|
||||||
_, err := q.db.ExecContext(ctx, insertUpload,
|
row := q.db.QueryRowContext(ctx, insertUpload,
|
||||||
arg.SiteID,
|
arg.SiteID,
|
||||||
arg.Guid,
|
arg.Guid,
|
||||||
arg.MimeType,
|
arg.MimeType,
|
||||||
|
|
@ -54,7 +54,9 @@ func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) erro
|
||||||
arg.Alt,
|
arg.Alt,
|
||||||
arg.CreatedAt,
|
arg.CreatedAt,
|
||||||
)
|
)
|
||||||
return err
|
var id int64
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectUploadByID = `-- name: SelectUploadByID :one
|
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)
|
_, err := q.db.ExecContext(ctx, updateUpload, arg.Alt, arg.ID)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.28.0
|
// sqlc v1.30.0
|
||||||
// source: users.sql
|
// source: users.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,23 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDel
|
||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Provider) SelectPublishedPostsOfSite(ctx context.Context, siteID int64, pp PagingParams) ([]*models.Post, error) {
|
||||||
|
rows, err := db.queries.SelectPublishedPostsOfSite(ctx, sqlgen.SelectPublishedPostsOfSiteParams{
|
||||||
|
SiteID: siteID,
|
||||||
|
Limit: pp.Limit,
|
||||||
|
Offset: pp.Offset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
posts := make([]*models.Post, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
posts[i] = dbPostToPost(row)
|
||||||
|
}
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *Provider) SelectPost(ctx context.Context, postID int64) (*models.Post, error) {
|
func (db *Provider) SelectPost(ctx context.Context, postID int64) (*models.Post, error) {
|
||||||
row, err := db.queries.SelectPost(ctx, postID)
|
row, err := db.queries.SelectPost(ctx, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ func (db *Provider) SelectUploadBySiteIDAndSlug(ctx context.Context, siteID int6
|
||||||
|
|
||||||
func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error {
|
func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error {
|
||||||
if upload.ID == 0 {
|
if upload.ID == 0 {
|
||||||
if err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{
|
newID, err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{
|
||||||
SiteID: upload.SiteID,
|
SiteID: upload.SiteID,
|
||||||
Guid: upload.GUID,
|
Guid: upload.GUID,
|
||||||
MimeType: upload.MIMEType,
|
MimeType: upload.MIMEType,
|
||||||
|
|
@ -53,9 +53,11 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error
|
||||||
Slug: upload.Slug,
|
Slug: upload.Slug,
|
||||||
Alt: upload.Alt,
|
Alt: upload.Alt,
|
||||||
CreatedAt: upload.CreatedAt.Unix(),
|
CreatedAt: upload.CreatedAt.Unix(),
|
||||||
}); err != nil {
|
})
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
upload.ID = newID
|
||||||
return nil
|
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 {
|
func (db *Provider) DeleteUpload(ctx context.Context, id int64) error {
|
||||||
return db.queries.DeleteUpload(ctx, id)
|
return db.queries.DeleteUpload(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ type Renderer struct {
|
||||||
|
|
||||||
func NewRendererForUI() *Renderer {
|
func NewRendererForUI() *Renderer {
|
||||||
mdParser := goldmark.New(
|
mdParser := goldmark.New(
|
||||||
goldmark.WithExtensions(extension.GFM),
|
goldmark.WithExtensions(extension.GFM, extension.Footnote),
|
||||||
goldmark.WithRendererOptions(
|
goldmark.WithRendererOptions(
|
||||||
gm_html.WithUnsafe(),
|
gm_html.WithUnsafe(),
|
||||||
),
|
),
|
||||||
|
|
@ -48,7 +48,7 @@ func NewRendererForUI() *Renderer {
|
||||||
|
|
||||||
func NewRendererForSite() *Renderer {
|
func NewRendererForSite() *Renderer {
|
||||||
mdParser := goldmark.New(
|
mdParser := goldmark.New(
|
||||||
goldmark.WithExtensions(extension.GFM),
|
goldmark.WithExtensions(extension.GFM, extension.Footnote),
|
||||||
goldmark.WithParserOptions(
|
goldmark.WithParserOptions(
|
||||||
parser.WithAutoHeadingID(),
|
parser.WithAutoHeadingID(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ func New(site pubmodel.Site, opts Options) (*Builder, error) {
|
||||||
mdRenderer: markdown.NewRendererForSite(),
|
mdRenderer: markdown.NewRendererForSite(),
|
||||||
postMDProcessors: []postMDProcessor{
|
postMDProcessors: []postMDProcessor{
|
||||||
uploadAbsoluteURL,
|
uploadAbsoluteURL,
|
||||||
|
removeFootnoteHRs,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,3 +35,8 @@ func uploadAbsoluteURL(site pubmodel.Site, dom *goquery.Document) error {
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeFootnoteHRs(site pubmodel.Site, dom *goquery.Document) error {
|
||||||
|
dom.Find("div.footnotes > hr").Remove()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ func copyFile(src, dst string) error {
|
||||||
return err
|
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) {
|
func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) {
|
||||||
fullPath := p.uploadFileName(site, up)
|
fullPath := p.uploadFileName(site, up)
|
||||||
return os.Open(fullPath)
|
return os.Open(fullPath)
|
||||||
|
|
|
||||||
171
services/imgedit/processing.go
Normal file
171
services/imgedit/processing.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
266
services/imgedit/service.go
Normal file
266
services/imgedit/service.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
35
services/imgedit/shadow.go
Normal file
35
services/imgedit/shadow.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
70
services/imgedit/store.go
Normal file
70
services/imgedit/store.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -9,10 +9,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// postIter returns a post iterator which returns posts in reverse chronological order.
|
// postIter returns a post iterator which returns posts in reverse chronological order.
|
||||||
func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
|
func (s *Publisher) publishedPostIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
|
||||||
return func(yield func(models.Maybe[*models.Post]) bool) {
|
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||||||
paging := db.PagingParams{Offset: 0, Limit: 50}
|
paging := db.PagingParams{Offset: 0, Limit: 50}
|
||||||
page, err := s.db.SelectPostsOfSite(ctx, site, false, paging)
|
page, err := s.db.SelectPublishedPostsOfSite(ctx, site, paging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(models.Maybe[*models.Post]{Err: err})
|
yield(models.Maybe[*models.Post]{Err: err})
|
||||||
return
|
return
|
||||||
|
|
@ -45,7 +45,7 @@ func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) it
|
||||||
return func(yield func(models.Maybe[*models.Post]) bool) {
|
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||||||
paging := db.PagingParams{Offset: 0, Limit: 50}
|
paging := db.PagingParams{Offset: 0, Limit: 50}
|
||||||
for {
|
for {
|
||||||
page, err := s.db.SelectPostsOfCategory(ctx, categoryID, paging)
|
page, err := s.db.SelectPublishedPostsOfCategory(ctx, categoryID, paging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(models.Maybe[*models.Post]{Err: err})
|
yield(models.Maybe[*models.Post]{Err: err})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
|
||||||
pubSite := pubmodel.Site{
|
pubSite := pubmodel.Site{
|
||||||
Site: site,
|
Site: site,
|
||||||
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
|
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
|
||||||
return p.postIter(ctx, site.ID)
|
return p.publishedPostIter(ctx, site.ID)
|
||||||
},
|
},
|
||||||
BaseURL: target.BaseURL,
|
BaseURL: target.BaseURL,
|
||||||
Uploads: uploads,
|
Uploads: uploads,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"lmika.dev/lmika/weiro/providers/uploadfiles"
|
"lmika.dev/lmika/weiro/providers/uploadfiles"
|
||||||
"lmika.dev/lmika/weiro/services/auth"
|
"lmika.dev/lmika/weiro/services/auth"
|
||||||
"lmika.dev/lmika/weiro/services/categories"
|
"lmika.dev/lmika/weiro/services/categories"
|
||||||
|
"lmika.dev/lmika/weiro/services/imgedit"
|
||||||
"lmika.dev/lmika/weiro/services/pages"
|
"lmika.dev/lmika/weiro/services/pages"
|
||||||
"lmika.dev/lmika/weiro/services/posts"
|
"lmika.dev/lmika/weiro/services/posts"
|
||||||
"lmika.dev/lmika/weiro/services/publisher"
|
"lmika.dev/lmika/weiro/services/publisher"
|
||||||
|
|
@ -23,6 +24,7 @@ type Services struct {
|
||||||
Posts *posts.Service
|
Posts *posts.Service
|
||||||
Sites *sites.Service
|
Sites *sites.Service
|
||||||
Uploads *uploads.Service
|
Uploads *uploads.Service
|
||||||
|
ImageEdit *imgedit.Service
|
||||||
Categories *categories.Service
|
Categories *categories.Service
|
||||||
Pages *pages.Service
|
Pages *pages.Service
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +43,7 @@ func New(cfg config.Config) (*Services, error) {
|
||||||
postService := posts.New(dbp, publisherQueue)
|
postService := posts.New(dbp, publisherQueue)
|
||||||
siteService := sites.New(dbp)
|
siteService := sites.New(dbp)
|
||||||
uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
|
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)
|
categoriesService := categories.New(dbp, publisherQueue)
|
||||||
pagesService := pages.New(dbp, publisherQueue)
|
pagesService := pages.New(dbp, publisherQueue)
|
||||||
|
|
||||||
|
|
@ -52,6 +55,7 @@ func New(cfg config.Config) (*Services, error) {
|
||||||
Posts: postService,
|
Posts: postService,
|
||||||
Sites: siteService,
|
Sites: siteService,
|
||||||
Uploads: uploadService,
|
Uploads: uploadService,
|
||||||
|
ImageEdit: imageEditService,
|
||||||
Categories: categoriesService,
|
Categories: categoriesService,
|
||||||
Pages: pagesService,
|
Pages: pagesService,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
"lmika.dev/lmika/weiro/providers/db"
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
|
"lmika.dev/pkg/modash/moslice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
|
@ -25,6 +26,22 @@ func (s *Service) HasUsersAsSites(ctx context.Context) (bool, error) {
|
||||||
return s.db.HasUsersAndSites(ctx)
|
return s.db.HasUsersAndSites(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListSites(ctx context.Context) ([]models.Site, error) {
|
||||||
|
user, ok := models.GetUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, models.UserRequiredError
|
||||||
|
}
|
||||||
|
|
||||||
|
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if len(sites) == 0 {
|
||||||
|
return nil, errors.New("no sites found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sites, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) {
|
func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) {
|
||||||
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
|
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -36,16 +53,20 @@ func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site,
|
||||||
return sites[0], nil
|
return sites[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirstRunRequest struct {
|
type CreateSiteParams struct {
|
||||||
Username string `form:"username"`
|
|
||||||
Password1 string `form:"password1"`
|
|
||||||
Password2 string `form:"password2"`
|
|
||||||
SiteName string `form:"siteName"`
|
SiteName string `form:"siteName"`
|
||||||
SiteURL string `form:"siteUrl"`
|
SiteURL string `form:"siteUrl"`
|
||||||
NetlifySiteID string `form:"netlifySiteId"`
|
NetlifySiteID string `form:"netlifySiteId"`
|
||||||
NetlifyAPIKey string `form:"netlifyAPIToken"`
|
NetlifyAPIKey string `form:"netlifyAPIToken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FirstRunRequest struct {
|
||||||
|
CreateSiteParams
|
||||||
|
Username string `form:"username"`
|
||||||
|
Password1 string `form:"password1"`
|
||||||
|
Password2 string `form:"password2"`
|
||||||
|
}
|
||||||
|
|
||||||
func (frr FirstRunRequest) Validate() error {
|
func (frr FirstRunRequest) Validate() error {
|
||||||
return validation.ValidateStruct(&frr,
|
return validation.ValidateStruct(&frr,
|
||||||
validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)),
|
validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)),
|
||||||
|
|
@ -76,16 +97,31 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo
|
||||||
return newUser, newSite, err
|
return newUser, newSite, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx = models.WithUser(ctx, newUser)
|
||||||
|
newSite, err = s.CreateSite(ctx, req.CreateSiteParams)
|
||||||
|
if err != nil {
|
||||||
|
return newUser, newSite, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newUser, newSite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateSite(ctx context.Context, req CreateSiteParams) (newSite models.Site, _ error) {
|
||||||
|
user, ok := models.GetUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
return newSite, models.UserRequiredError
|
||||||
|
}
|
||||||
|
|
||||||
newSite = models.Site{
|
newSite = models.Site{
|
||||||
Title: defaultIfEmpty(req.SiteName, "New Site"),
|
Title: defaultIfEmpty(req.SiteName, "New Site"),
|
||||||
GUID: models.NewNanoID(),
|
GUID: models.NewNanoID(),
|
||||||
OwnerID: newUser.ID,
|
OwnerID: user.ID,
|
||||||
Timezone: "UTC",
|
Timezone: "UTC",
|
||||||
PostsPerPage: 10,
|
PostsPerPage: 10,
|
||||||
Created: time.Now(),
|
Created: time.Now(),
|
||||||
}
|
}
|
||||||
if err := s.db.SaveSite(ctx, &newSite); err != nil {
|
if err := s.db.SaveSite(ctx, &newSite); err != nil {
|
||||||
return newUser, newSite, err
|
return newSite, err
|
||||||
}
|
}
|
||||||
|
|
||||||
hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != ""
|
hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != ""
|
||||||
|
|
@ -100,11 +136,11 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo
|
||||||
TargetKey: req.NetlifyAPIKey,
|
TargetKey: req.NetlifyAPIKey,
|
||||||
}
|
}
|
||||||
if err := s.db.SavePublishTarget(ctx, &target); err != nil {
|
if err := s.db.SavePublishTarget(ctx, &target); err != nil {
|
||||||
return newUser, newSite, err
|
return newSite, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newUser, newSite, nil
|
return newSite, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) {
|
func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) {
|
||||||
|
|
@ -166,3 +202,17 @@ func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSetti
|
||||||
|
|
||||||
return site, nil
|
return site, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) BestPubTarget(ctx context.Context, site models.Site) (models.SitePublishTarget, error) {
|
||||||
|
pubTargets, err := s.db.SelectPublishTargetsOfSite(ctx, site.ID)
|
||||||
|
if err != nil {
|
||||||
|
return models.SitePublishTarget{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledPubTargets := moslice.Filter(pubTargets, func(pubTarget models.SitePublishTarget) bool { return pubTarget.Enabled })
|
||||||
|
if len(enabledPubTargets) == 0 {
|
||||||
|
return models.SitePublishTarget{}, errors.New("no publish targets found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return enabledPubTargets[0], nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
)
|
)
|
||||||
|
|
@ -67,6 +70,75 @@ func (s *Service) renderCopyTemplate(upload models.Upload) string {
|
||||||
return sb.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) {
|
func (s *Service) ListUploads(ctx context.Context) (res []UploadWithURL, _ error) {
|
||||||
site, _, err := s.fetchSiteAndUser(ctx)
|
site, _, err := s.fetchSiteAndUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ INNER JOIN post_categories pc ON pc.category_id = c.id
|
||||||
WHERE pc.post_id = ?
|
WHERE pc.post_id = ?
|
||||||
ORDER BY c.name ASC;
|
ORDER BY c.name ASC;
|
||||||
|
|
||||||
-- name: SelectPostsOfCategory :many
|
-- name: SelectPublishedPostsOfCategory :many
|
||||||
SELECT p.* FROM posts p
|
SELECT p.* FROM posts p
|
||||||
INNER JOIN post_categories pc ON pc.post_id = p.id
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||||||
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ WHERE site_id = sqlc.arg(site_id) AND (
|
||||||
END
|
END
|
||||||
) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset);
|
) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset);
|
||||||
|
|
||||||
|
-- name: SelectPublishedPostsOfSite :many
|
||||||
|
SELECT *
|
||||||
|
FROM posts
|
||||||
|
WHERE site_id = sqlc.arg(site_id) AND state = 0 AND deleted_at = 0
|
||||||
|
ORDER BY published_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset);
|
||||||
|
|
||||||
-- name: SelectPost :one
|
-- name: SelectPost :one
|
||||||
SELECT * FROM posts WHERE id = ? LIMIT 1;
|
SELECT * FROM posts WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ SELECT * FROM uploads WHERE id = ? LIMIT 1;
|
||||||
-- name: SelectUploadBySiteIDAndSlug :one
|
-- name: SelectUploadBySiteIDAndSlug :one
|
||||||
SELECT * FROM uploads WHERE site_id = ? AND slug = ? LIMIT 1;
|
SELECT * FROM uploads WHERE site_id = ? AND slug = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: InsertUpload :exec
|
-- name: InsertUpload :one
|
||||||
INSERT INTO uploads (
|
INSERT INTO uploads (
|
||||||
site_id,
|
site_id,
|
||||||
guid,
|
guid,
|
||||||
|
|
@ -23,5 +23,8 @@ RETURNING id;
|
||||||
-- name: UpdateUpload :exec
|
-- name: UpdateUpload :exec
|
||||||
UPDATE uploads SET alt = ? WHERE id = ?;
|
UPDATE uploads SET alt = ? WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: UpdateUploadFileSize :exec
|
||||||
|
UPDATE uploads SET file_size = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteUpload :exec
|
-- name: DeleteUpload :exec
|
||||||
DELETE FROM uploads WHERE id = ?;
|
DELETE FROM uploads WHERE id = ?;
|
||||||
|
|
@ -29,7 +29,25 @@
|
||||||
<span class="visually-hidden">Publishing...</span>
|
<span class="visually-hidden">Publishing...</span>
|
||||||
</div>
|
</div>
|
||||||
-->
|
-->
|
||||||
|
<div class="nav-item dropdown me-2">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
{{ .site.Title }}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
{{ range .allSites }}
|
||||||
|
<li><a class="dropdown-item" href="/sites/{{.ID}}/posts">{{.Title}}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="/sites/new">New Site…</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item dropdown border-end me-3">
|
||||||
|
{{ if .pubTarget }}
|
||||||
|
<a href="{{.pubTarget.BaseURL}}" class="nav-link navbar-site-visit" target="_blank" title="Visit site">
|
||||||
|
<i data-feather="external-link" width="18" height="18"></i>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
<div class="nav-item dropdown">
|
<div class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
{{ .user.Username }}
|
{{ .user.Username }}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div data-first-run-target="pages">
|
<div data-first-run-target="pages">
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<p>Enter the details of your blog, if you know them.<br>All fields are optional, and can be changed later.</p>
|
<p>Enter the details of your blog if you know them.<br>All fields are optional and can be changed later.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label for="siteName" class="form-label">Site Name</label>
|
<label for="siteName" class="form-label">Site Name</label>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
</table>
|
</table>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="h4 m-3 text-center">
|
<div class="h4 m-3 text-center">
|
||||||
<div class="position-absolute top-50 start-50 translate-middle">No pages yet.</div>
|
<div class="position-absolute top-50 start-50 translate-middle">📄<br>No pages yet.</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{{ $isPublished := ne .post.State 1 }}
|
{{ $isPublished := ne .post.State 1 }}
|
||||||
<main class="flex-grow-1 position-relative">
|
<main class="flex-grow-1 position-relative post-edit-page">
|
||||||
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form py-2"
|
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form py-2"
|
||||||
data-controller="postedit"
|
data-controller="postedit"
|
||||||
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
|
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
|
||||||
|
|
|
||||||
29
views/sitesettings/new.html
Normal file
29
views/sitesettings/new.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<div class="mx-auto p-2" style="width: 400px; margin-block-start: 50px;" data-controller="first-run">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h1>New Site</h1>
|
||||||
|
</div>
|
||||||
|
<form action="/sites" method="post">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<p>Enter the details of your blog if you know them.<br>All fields are optional and can be changed later.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="siteName" class="form-label">Site Name</label>
|
||||||
|
<input type="text" class="form-control" name="siteName" id="siteName">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="siteUrl" class="form-label">Site URL</label>
|
||||||
|
<input type="text" class="form-control" name="siteUrl" id="siteUrl">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="netlifySiteId" class="form-label">Netlify Site ID</label>
|
||||||
|
<input type="text" class="form-control" name="netlifySiteId" id="netlifySiteId">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="netlifyAPIToken" class="form-label">Netlify API Token</label>
|
||||||
|
<input type="text" class="form-control" name="netlifyAPIToken" id="netlifyAPIToken">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-end">
|
||||||
|
<input type="submit" class="btn btn-primary" value="Create Site">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
31
views/uploads/edit.html
Normal file
31
views/uploads/edit.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<main class="flex-grow-1 flex-shrink-1"
|
||||||
|
data-controller="edit-upload"
|
||||||
|
data-edit-upload-site-id-value="{{ .site.ID }}"
|
||||||
|
data-edit-upload-upload-id-value="{{ .upload.Upload.ID }}"
|
||||||
|
>
|
||||||
|
<div class="flex-grow-1 flex-shrink-1 d-flex flex-column">
|
||||||
|
<div class="row flex-grow-1 flex-shrink-1 m-0">
|
||||||
|
<figure class="col-md-9 p-3">
|
||||||
|
<img data-edit-upload-target="preview" src="{{ .upload.URL }}" alt="{{ .upload.Upload.Alt }}" class="img-fluid" style="max-height: 80vw;">
|
||||||
|
</figure>
|
||||||
|
<div class="col-md-3 p-3">
|
||||||
|
<div data-edit-upload-target="processList"></div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Add Processor
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="#" data-action="edit-upload#addProcessor">Shadow</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
|
@ -20,5 +20,9 @@
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="h4 m-3 text-center">
|
||||||
|
<div class="position-absolute top-50 start-50 translate-middle">🖼️<br>No uploads yet.</div>
|
||||||
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -5,7 +5,10 @@
|
||||||
data-show-upload-site-id-value="{{ .upload.Upload.SiteID }}"
|
data-show-upload-site-id-value="{{ .upload.Upload.SiteID }}"
|
||||||
data-show-upload-upload-id-value="{{ .upload.Upload.ID }}">
|
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-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>
|
</div>
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue