feat: article attachments with content-addressed dedup
Add an attachments table and AJAX upload/management on the article create and edit pages. - Attachment model with article_id (0 while pending on create), session_token ownership, and SHA-256 content-addressed stored_name - Upload validates via the platform policy (switch/type/size) and deduplicates on disk by content hash - Plan-A binding: attachments uploaded before an article exists are owned by a session token and bound to the new article on save - Delete soft-removes the record and drops the disk file only when no remaining rows reference it (reference counting for deduped files) - Edit page loads existing attachments via JSON list endpoint - Row actions: insert into body (markdown image/link), set as cover (image only), and delete - Download URLs use the configured default download base URL when set, else the local /uploads path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+38
-12
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -17,6 +19,17 @@ import (
|
||||
// slugRe matches characters that are not URL-safe.
|
||||
var slugRe = regexp.MustCompile(`[^a-z0-9\-]+`)
|
||||
|
||||
// randomToken returns a 32-byte hex token used to own pending attachments on
|
||||
// the article-create page until the article is saved.
|
||||
func randomToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Extremely unlikely; fall back to a time-based value.
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateSlug converts a title into a URL-friendly slug.
|
||||
func generateSlug(title string) string {
|
||||
s := strings.ToLower(title)
|
||||
@@ -31,15 +44,17 @@ func generateSlug(title string) string {
|
||||
// articleForm holds the parsed article form fields, shared by the create and
|
||||
// edit handlers and their validation-error repopulation paths.
|
||||
type articleForm struct {
|
||||
Title string
|
||||
Slug string
|
||||
Summary string
|
||||
Content string
|
||||
Cover string
|
||||
StatusStr string
|
||||
IsTop bool
|
||||
Action string // form action URL
|
||||
TitleText string // page heading text (create vs edit)
|
||||
Title string
|
||||
Slug string
|
||||
Summary string
|
||||
Content string
|
||||
Cover string
|
||||
StatusStr string
|
||||
IsTop bool
|
||||
Action string // form action URL
|
||||
TitleText string // page heading text (create vs edit)
|
||||
ArticleID uint // existing article ID (edit page); 0 on create
|
||||
SessionToken string // pending-attachment ownership token (create page)
|
||||
}
|
||||
|
||||
// parseArticleForm reads and trims the article form fields from the request.
|
||||
@@ -67,6 +82,8 @@ func applyFormToData(data gin.H, f articleForm) {
|
||||
data["FormIsTop"] = f.IsTop
|
||||
data["FormAction"] = f.Action
|
||||
data["FormTitleText"] = f.TitleText
|
||||
data["FormArticleID"] = f.ArticleID
|
||||
data["SessionToken"] = f.SessionToken
|
||||
}
|
||||
|
||||
// renderArticleForm renders the shared article form template with the given
|
||||
@@ -117,9 +134,10 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
renderArticleForm(c, db, articleForm{
|
||||
Action: "/admin/articles/new",
|
||||
TitleText: tr["article_create_title"],
|
||||
StatusStr: "0",
|
||||
Action: "/admin/articles/new",
|
||||
TitleText: tr["article_create_title"],
|
||||
StatusStr: "0",
|
||||
SessionToken: randomToken(),
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
@@ -131,6 +149,7 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
f := parseArticleForm(c)
|
||||
f.Action = "/admin/articles/new"
|
||||
f.TitleText = tr["article_create_title"]
|
||||
f.SessionToken = strings.TrimSpace(c.PostForm("session_token"))
|
||||
|
||||
// Validate required fields.
|
||||
if f.Title == "" {
|
||||
@@ -178,6 +197,12 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Bind any attachments uploaded during creation (plan A: pending rows
|
||||
// owned by session_token, article_id=0).
|
||||
if f.SessionToken != "" {
|
||||
_ = BindPendingAttachments(db, f.SessionToken, article.ID)
|
||||
}
|
||||
|
||||
// Success: redirect to dashboard.
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
}
|
||||
@@ -218,6 +243,7 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
IsTop: article.IsTop,
|
||||
Action: "/admin/articles/" + id + "/edit",
|
||||
TitleText: tr["article_edit_title"],
|
||||
ArticleID: article.ID,
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// attachmentsDir returns the on-disk directory for attachments under the given
|
||||
// storage path, honoring the configured storage_dir.
|
||||
func attachmentsDir(storagePath string) string {
|
||||
dir := models.GetUploadConfig().StorageDir
|
||||
if dir == "" {
|
||||
dir = "attachments"
|
||||
}
|
||||
return filepath.Join(storagePath, dir)
|
||||
}
|
||||
|
||||
// attachmentRelPath returns the path of an attachment relative to the storage
|
||||
// root, e.g. "attachments/<stored>" — used to build /uploads URLs.
|
||||
func attachmentRelPath(stored string) string {
|
||||
dir := models.GetUploadConfig().StorageDir
|
||||
if dir == "" {
|
||||
dir = "attachments"
|
||||
}
|
||||
return dir + "/" + stored
|
||||
}
|
||||
|
||||
// attachmentURL builds the public URL for an attachment: the default download
|
||||
// base URL (if configured) joined with the relative path, else the local
|
||||
// /uploads path served by the app.
|
||||
func attachmentURL(stored string) string {
|
||||
rel := attachmentRelPath(stored)
|
||||
if base := models.DefaultDownloadBaseURL(); base != "" {
|
||||
return strings.TrimRight(base, "/") + "/" + rel
|
||||
}
|
||||
return "/uploads/" + rel
|
||||
}
|
||||
|
||||
// ---------------- Upload ----------------
|
||||
|
||||
// UploadAttachment handles AJAX attachment uploads from the article create/edit
|
||||
// form. The request carries either a real article_id (edit page) or a
|
||||
// session_token (create page, pending binding). Files are content-addressed by
|
||||
// SHA-256 for on-disk deduplication.
|
||||
func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uploaderID, ok := sessionAuthorID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
articleID := parseUintForm(c, "article_id")
|
||||
token := strings.TrimSpace(c.PostForm("session_token"))
|
||||
// On the create page the article does not exist yet; require a token.
|
||||
if articleID == 0 && token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing session_token"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate against the platform upload policy (switch + type + size).
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK {
|
||||
if !models.GetUploadConfig().Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "uploads are disabled"})
|
||||
return
|
||||
}
|
||||
if check.Type != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file too large; limit is %s", formatSize(check.MaxSize))})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read fully to compute the content hash.
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
stored := hex.EncodeToString(sum[:])
|
||||
|
||||
// Deduplicate on disk: only write when the file is absent.
|
||||
dir := attachmentsDir(storagePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create storage dir"})
|
||||
return
|
||||
}
|
||||
dstPath := filepath.Join(dir, stored)
|
||||
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(dstPath, content, 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
att := models.Attachment{
|
||||
ArticleID: articleID,
|
||||
SessionToken: token,
|
||||
UploaderID: uploaderID,
|
||||
Filename: header.Filename,
|
||||
StoredName: stored,
|
||||
Ext: ext,
|
||||
MIME: header.Header.Get("Content-Type"),
|
||||
Size: header.Size,
|
||||
Category: check.Type.Category,
|
||||
}
|
||||
if err := db.Create(&att).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record attachment"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": att.ID,
|
||||
"filename": att.Filename,
|
||||
"size": att.Size,
|
||||
"category": att.Category,
|
||||
"url": attachmentURL(att.StoredName),
|
||||
"is_image": att.IsImage(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Delete ----------------
|
||||
|
||||
// DeleteAttachment soft-deletes an attachment record and removes the on-disk
|
||||
// file only when no remaining records reference it (reference counting, since
|
||||
// content-addressed files may be shared).
|
||||
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := parseUintParam(c, "id")
|
||||
var att models.Attachment
|
||||
if err := db.First(&att, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
stored := att.StoredName
|
||||
|
||||
if err := db.Delete(&att).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reference count: any other (non-deleted) rows pointing at this file?
|
||||
var count int64
|
||||
db.Model(&models.Attachment{}).Where("stored_name = ?", stored).Count(&count)
|
||||
if count == 0 {
|
||||
os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // ignore error
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- List ----------------
|
||||
|
||||
// ListAttachments returns the attachments for an article as JSON (used by the
|
||||
// edit page to repopulate the list on load).
|
||||
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
articleID := parseUintParam(c, "id")
|
||||
var atts []models.Attachment
|
||||
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
|
||||
|
||||
out := make([]gin.H, 0, len(atts))
|
||||
for _, a := range atts {
|
||||
out = append(out, gin.H{
|
||||
"id": a.ID,
|
||||
"filename": a.Filename,
|
||||
"size": a.Size,
|
||||
"category": a.Category,
|
||||
"url": attachmentURL(a.StoredName),
|
||||
"is_image": a.IsImage(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"attachments": out})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Binding (plan A) ----------------
|
||||
|
||||
// BindPendingAttachments attaches attachments uploaded during article creation
|
||||
// (owned by session_token, article_id=0) to a newly created article. Called by
|
||||
// ArticleCreate after the article row is saved.
|
||||
func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
return db.Model(&models.Attachment{}).
|
||||
Where("session_token = ? AND article_id = 0", token).
|
||||
Updates(map[string]interface{}{"article_id": articleID, "session_token": ""}).Error
|
||||
}
|
||||
|
||||
// ---------------- helpers ----------------
|
||||
|
||||
// parseUintForm parses a uint form field, tolerating empty/invalid input.
|
||||
func parseUintForm(c *gin.Context, field string) uint {
|
||||
v := strings.TrimSpace(c.PostForm(field))
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
var n uint
|
||||
_, _ = fmt.Sscanf(v, "%d", &n)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseUintParam parses a uint route param.
|
||||
func parseUintParam(c *gin.Context, name string) uint {
|
||||
var n uint
|
||||
_, _ = fmt.Sscanf(c.Param(name), "%d", &n)
|
||||
return n
|
||||
}
|
||||
@@ -121,6 +121,17 @@ var translations = map[Lang]map[string]string{
|
||||
"article_content_required": "Content is required.",
|
||||
"article_back_home": "Back to Home",
|
||||
"article_not_found": "Article not found.",
|
||||
"article_attachments": "Attachments",
|
||||
"article_upload": "Upload",
|
||||
"article_att_name": "File",
|
||||
"article_att_size": "Size",
|
||||
"article_att_insert": "Insert",
|
||||
"article_att_set_cover": "Set as Cover",
|
||||
"article_att_cover_set": "Cover URL set.",
|
||||
"article_att_pick": "Please choose a file.",
|
||||
"article_att_uploading": "Uploading...",
|
||||
"article_att_error": "Upload failed. Please try again.",
|
||||
"article_att_delete_confirm": "Delete this attachment?",
|
||||
|
||||
// Article management
|
||||
"article_list_title": "Articles",
|
||||
@@ -293,6 +304,17 @@ var translations = map[Lang]map[string]string{
|
||||
"article_content_required": "正文不能为空。",
|
||||
"article_back_home": "返回首页",
|
||||
"article_not_found": "文章不存在。",
|
||||
"article_attachments": "附件",
|
||||
"article_upload": "上传",
|
||||
"article_att_name": "文件",
|
||||
"article_att_size": "大小",
|
||||
"article_att_insert": "插入正文",
|
||||
"article_att_set_cover": "设为封面",
|
||||
"article_att_cover_set": "已设为封面。",
|
||||
"article_att_pick": "请先选择文件。",
|
||||
"article_att_uploading": "上传中...",
|
||||
"article_att_error": "上传失败,请重试。",
|
||||
"article_att_delete_confirm": "删除该附件?",
|
||||
|
||||
// 文章管理
|
||||
"article_list_title": "文章管理",
|
||||
|
||||
@@ -68,6 +68,15 @@ func main() {
|
||||
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
||||
}
|
||||
|
||||
// Protected article attachment routes (AJAX uploads / management).
|
||||
attachments := router.Group("/admin/articles")
|
||||
attachments.Use(middleware.AuthRequired())
|
||||
{
|
||||
attachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
attachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
attachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
|
||||
// Protected admin settings routes (platform configuration).
|
||||
settings := router.Group("/admin/settings")
|
||||
settings.Use(middleware.AuthRequired())
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Attachment represents a file attached to an article.
|
||||
//
|
||||
// Lifecycle (plan A — upload-then-bind):
|
||||
// - On the article-create page the article does not exist yet, so ArticleID
|
||||
// is 0 and the row is temporarily owned by SessionToken (a random value
|
||||
// generated for the page session).
|
||||
// - When the article is saved, ArticleCreate binds pending rows by
|
||||
// SessionToken, setting their ArticleID and clearing the token.
|
||||
// - On the edit page uploads carry the real ArticleID directly.
|
||||
//
|
||||
// Disk deduplication: StoredName is the SHA-256 of the file content. Before
|
||||
// writing, the handler checks whether a file with that name already exists on
|
||||
// disk; if so it is reused (no rewrite). Deletion uses reference counting —
|
||||
// the disk file is removed only when no Attachment rows reference it.
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
ArticleID uint `gorm:"index" json:"article_id"` // 0 while pending on the create page
|
||||
SessionToken string `gorm:"size:64;index" json:"-"` // temporary ownership token for the create page
|
||||
UploaderID uint `gorm:"index" json:"uploader_id"`
|
||||
Filename string `gorm:"size:255" json:"filename"` // original filename
|
||||
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 hex, the on-disk filename
|
||||
Ext string `gorm:"size:32" json:"ext"`
|
||||
MIME string `gorm:"size:128" json:"mime"`
|
||||
Size int64 `gorm:"default:0" json:"size"`
|
||||
Category string `gorm:"size:32" json:"category"` // image/document/archive/video/other
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (Attachment) TableName() string {
|
||||
return "attachments"
|
||||
}
|
||||
|
||||
// AttachmentCategoryImage reports whether this attachment is an image (used to
|
||||
// decide markdown insertion form: ![]() vs []()).
|
||||
func (a *Attachment) IsImage() bool {
|
||||
return a.Category == CategoryImage
|
||||
}
|
||||
+1
-1
@@ -45,7 +45,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
}
|
||||
|
||||
// Auto-migrate tables (idempotent).
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<!-- Hidden: attachment ownership (token on create, id on edit) -->
|
||||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
||||
@@ -50,6 +53,30 @@
|
||||
placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<input type="file" id="attachmentInput" class="text-sm text-gray-600" disabled>
|
||||
<button type="button" id="attachmentUploadBtn"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer disabled:opacity-50"
|
||||
disabled>
|
||||
{{index .Tr "article_upload"}}
|
||||
</button>
|
||||
<span id="attachmentMsg" class="text-xs text-gray-400"></span>
|
||||
</div>
|
||||
<table class="w-full text-left border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_name"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_size"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attachmentList" class="divide-y divide-gray-100"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- IsTop -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
|
||||
@@ -89,6 +116,115 @@ var easyMDE = new EasyMDE({
|
||||
status: false,
|
||||
minHeight: '300px'
|
||||
});
|
||||
|
||||
// ---- Attachments ----
|
||||
(function () {
|
||||
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
|
||||
var sessionToken = "{{ .SessionToken }}";
|
||||
var uploadBtn = document.getElementById('attachmentUploadBtn');
|
||||
var fileInput = document.getElementById('attachmentInput');
|
||||
var msgEl = document.getElementById('attachmentMsg');
|
||||
var listEl = document.getElementById('attachmentList');
|
||||
|
||||
// Enable the uploader (uploads allowed only while logged in, which is true here).
|
||||
uploadBtn.disabled = false;
|
||||
fileInput.disabled = false;
|
||||
|
||||
function fmtSize(b) {
|
||||
if (b < 1024) return b + ' B';
|
||||
var u = ['KiB', 'MiB', 'GiB'], i = -1;
|
||||
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
|
||||
return b.toFixed(1) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function addRow(att) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'hover:bg-gray-50';
|
||||
tr.dataset.id = att.id;
|
||||
var nameTd = document.createElement('td');
|
||||
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
|
||||
var link = document.createElement('a');
|
||||
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
|
||||
nameTd.appendChild(link);
|
||||
var sizeTd = document.createElement('td');
|
||||
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
|
||||
sizeTd.textContent = fmtSize(att.size);
|
||||
var actTd = document.createElement('td');
|
||||
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
|
||||
var insBtn = document.createElement('button');
|
||||
insBtn.type = 'button';
|
||||
insBtn.textContent = "{{index .Tr "article_att_insert"}}";
|
||||
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
insBtn.onclick = function () {
|
||||
var md = att.is_image
|
||||
? ''
|
||||
: '[' + att.filename + '](' + att.url + ')';
|
||||
var cm = easyMDE.codemirror;
|
||||
cm.replaceSelection(md + '\n');
|
||||
cm.focus();
|
||||
};
|
||||
|
||||
// Images: extra button to copy the URL into the cover field.
|
||||
var coverBtn = null;
|
||||
if (att.is_image) {
|
||||
coverBtn = document.createElement('button');
|
||||
coverBtn.type = 'button';
|
||||
coverBtn.textContent = "{{index .Tr "article_att_set_cover"}}";
|
||||
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
coverBtn.onclick = function () {
|
||||
var cover = document.querySelector('input[name="cover"]');
|
||||
if (cover) { cover.value = att.url; msgEl.textContent = "{{index .Tr "article_att_cover_set"}}"; }
|
||||
};
|
||||
}
|
||||
var delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.textContent = "{{index .Tr "settings_delete"}}";
|
||||
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
|
||||
delBtn.onclick = function () {
|
||||
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
|
||||
fetch('/admin/articles/attachments/' + att.id + '/delete', { method: 'POST' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) { tr.remove(); }
|
||||
else { msgEl.textContent = r.error || 'error'; }
|
||||
});
|
||||
};
|
||||
actTd.appendChild(insBtn);
|
||||
if (coverBtn) { actTd.appendChild(coverBtn); }
|
||||
actTd.appendChild(delBtn);
|
||||
tr.appendChild(nameTd);
|
||||
tr.appendChild(sizeTd);
|
||||
tr.appendChild(actTd);
|
||||
listEl.appendChild(tr);
|
||||
}
|
||||
|
||||
uploadBtn.addEventListener('click', function () {
|
||||
if (!fileInput.files.length) { msgEl.textContent = "{{index .Tr "article_att_pick"}}"; return; }
|
||||
var fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
if (articleID) { fd.append('article_id', articleID); }
|
||||
else { fd.append('session_token', sessionToken); }
|
||||
msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
|
||||
fetch('/admin/articles/attachments', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.error) { msgEl.textContent = r.error; return; }
|
||||
msgEl.textContent = '';
|
||||
addRow(r);
|
||||
fileInput.value = '';
|
||||
})
|
||||
.catch(function () { msgEl.textContent = "{{index .Tr "article_att_error"}}"; });
|
||||
});
|
||||
|
||||
// Edit page: load existing attachments.
|
||||
if (articleID) {
|
||||
fetch('/admin/articles/' + articleID + '/attachments')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
(r.attachments || []).forEach(addRow);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user