feat: add avatar cropping with Cropper.js and server-side resize
This commit is contained in:
@@ -48,6 +48,7 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/image v0.43.0 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
|||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||||
|
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,6 +16,9 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
xdraw "golang.org/x/image/draw"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
"go_blog/models"
|
"go_blog/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -147,3 +153,92 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
|||||||
c.Redirect(http.StatusFound, "/profile?saved=1")
|
c.Redirect(http.StatusFound, "/profile?saved=1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadAvatar handles AJAX avatar upload with cropping.
|
||||||
|
func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
userID := session.Get("user_id")
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := db.First(&user, userID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "User not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := c.Request.FormFile("avatar")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Validate extension.
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
if ext == "" || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") {
|
||||||
|
ext = ".jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file bytes for image processing.
|
||||||
|
imgBytes, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode, resize, and re-encode the image.
|
||||||
|
processedBytes, finalExt, err := processAvatar(imgBytes, ext)
|
||||||
|
if err != nil {
|
||||||
|
// Fall back to saving raw bytes if processing fails.
|
||||||
|
processedBytes = imgBytes
|
||||||
|
finalExt = ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure avatar directory exists.
|
||||||
|
avatarDir := filepath.Join(storagePath, "avatars")
|
||||||
|
os.MkdirAll(avatarDir, 0755)
|
||||||
|
|
||||||
|
// Remove old avatar file.
|
||||||
|
if user.Avatar != "" {
|
||||||
|
oldPath := filepath.Join(avatarDir, user.Avatar)
|
||||||
|
os.Remove(oldPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save processed avatar.
|
||||||
|
savedName := fmt.Sprintf("%d%s", user.ID, finalExt)
|
||||||
|
savedPath := filepath.Join(avatarDir, savedName)
|
||||||
|
if err := os.WriteFile(savedPath, processedBytes, 0644); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user record.
|
||||||
|
user.Avatar = savedName
|
||||||
|
db.Save(&user)
|
||||||
|
|
||||||
|
// Update session.
|
||||||
|
session.Set("avatar", savedName)
|
||||||
|
session.Save()
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"avatar": savedName})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processAvatar decodes, resizes to 256x256, and re-encodes an avatar image as JPEG.
|
||||||
|
func processAvatar(imgBytes []byte, ext string) ([]byte, string, error) {
|
||||||
|
src, _, err := image.Decode(bytes.NewReader(imgBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSize = 256
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
|
||||||
|
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Src, nil)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), ".jpg", nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ var translations = map[Lang]map[string]string{
|
|||||||
"gender_male": "Male",
|
"gender_male": "Male",
|
||||||
"gender_female": "Female",
|
"gender_female": "Female",
|
||||||
"gender_other": "Other",
|
"gender_other": "Other",
|
||||||
|
|
||||||
|
// Avatar cropping
|
||||||
|
"crop_avatar_title": "Crop Avatar",
|
||||||
|
"crop_cancel": "Cancel",
|
||||||
|
"crop_confirm": "Confirm",
|
||||||
|
"crop_uploading": "Uploading...",
|
||||||
|
"crop_success": "Avatar updated.",
|
||||||
|
"crop_error": "Failed to upload avatar. Please try again.",
|
||||||
},
|
},
|
||||||
ZH: {
|
ZH: {
|
||||||
// 导航
|
// 导航
|
||||||
@@ -153,6 +161,14 @@ var translations = map[Lang]map[string]string{
|
|||||||
"gender_male": "男",
|
"gender_male": "男",
|
||||||
"gender_female": "女",
|
"gender_female": "女",
|
||||||
"gender_other": "其他",
|
"gender_other": "其他",
|
||||||
|
|
||||||
|
// 头像裁剪
|
||||||
|
"crop_avatar_title": "裁剪头像",
|
||||||
|
"crop_cancel": "取消",
|
||||||
|
"crop_confirm": "确认",
|
||||||
|
"crop_uploading": "上传中...",
|
||||||
|
"crop_success": "头像已更新。",
|
||||||
|
"crop_error": "头像上传失败,请重试。",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func main() {
|
|||||||
{
|
{
|
||||||
profile.GET("", handlers.ProfilePage(db))
|
profile.GET("", handlers.ProfilePage(db))
|
||||||
profile.POST("", handlers.UpdateProfile(db, cfg.Path))
|
profile.POST("", handlers.UpdateProfile(db, cfg.Path))
|
||||||
|
profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Start the server.
|
// 9. Start the server.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{.Title}} - {{index .Tr "site_title"}}</title>
|
<title>{{.Title}} - {{index .Tr "site_title"}}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
|
|||||||
@@ -15,24 +15,47 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Avatar Crop Modal -->
|
||||||
|
<div id="avatarCropModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "crop_avatar_title"}}</h3>
|
||||||
|
<div class="max-h-64 overflow-hidden mb-4 flex items-center justify-center bg-gray-100">
|
||||||
|
<img id="avatarCropImage" src="" alt="crop preview" class="max-w-full block">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<button type="button" id="cropCancelBtn"
|
||||||
|
class="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer">
|
||||||
|
{{index .Tr "crop_cancel"}}
|
||||||
|
</button>
|
||||||
|
<button type="button" id="cropConfirmBtn"
|
||||||
|
class="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors cursor-pointer">
|
||||||
|
{{index .Tr "crop_confirm"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form action="/profile" method="post" enctype="multipart/form-data" class="space-y-8">
|
<form action="/profile" method="post" enctype="multipart/form-data" class="space-y-8">
|
||||||
<!-- Avatar Section -->
|
<!-- Avatar Section -->
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "profile_avatar"}}</h3>
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "profile_avatar"}}</h3>
|
||||||
<div class="flex items-center gap-5">
|
<div class="flex items-center gap-5">
|
||||||
<div class="w-20 h-20 rounded-full overflow-hidden border-2 border-gray-300 flex items-center justify-center bg-gray-200">
|
<div id="avatarPreviewContainer" class="w-20 h-20 rounded-full overflow-hidden border-2 border-gray-300 flex items-center justify-center bg-gray-200">
|
||||||
{{if .Profile.Avatar}}
|
{{if .Profile.Avatar}}
|
||||||
<img src="/uploads/avatars/{{.Profile.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
<img id="avatarPreviewImg" src="/uploads/avatars/{{.Profile.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
||||||
{{else}}
|
{{else}}
|
||||||
<svg class="w-10 h-10 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
|
<svg id="avatarPreviewSvg" class="w-10 h-10 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
|
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<label class="cursor-pointer bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm">
|
<label class="cursor-pointer bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm">
|
||||||
{{index .Tr "profile_change_avatar"}}
|
{{index .Tr "profile_change_avatar"}}
|
||||||
<input type="file" name="avatar" accept="image/*" class="hidden" onchange="this.form.submit()">
|
<input type="file" id="avatarFileInput" name="avatar" accept="image/*" class="hidden">
|
||||||
</label>
|
</label>
|
||||||
|
<div id="avatarUploadStatus" class="mt-2 text-sm text-gray-500 hidden"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,4 +119,127 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var cropper = null;
|
||||||
|
var fileInput = document.getElementById('avatarFileInput');
|
||||||
|
var modal = document.getElementById('avatarCropModal');
|
||||||
|
var cropImage = document.getElementById('avatarCropImage');
|
||||||
|
var cancelBtn = document.getElementById('cropCancelBtn');
|
||||||
|
var confirmBtn = document.getElementById('cropConfirmBtn');
|
||||||
|
var statusDiv = document.getElementById('avatarUploadStatus');
|
||||||
|
var previewContainer = document.getElementById('avatarPreviewContainer');
|
||||||
|
|
||||||
|
// i18n strings from template
|
||||||
|
var i18nCropError = "{{index .Tr "crop_error"}}";
|
||||||
|
var i18nCropUploading = "{{index .Tr "crop_uploading"}}";
|
||||||
|
var i18nCropSuccess = "{{index .Tr "crop_success"}}";
|
||||||
|
var i18nCropConfirm = "{{index .Tr "crop_confirm"}}";
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', function() {
|
||||||
|
var file = this.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.match(/^image\/(jpeg|png|gif|webp)$/)) {
|
||||||
|
showStatus(i18nCropError, 'red');
|
||||||
|
this.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
cropImage.src = e.target.result;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
|
||||||
|
if (cropper) cropper.destroy();
|
||||||
|
cropper = new Cropper(cropImage, {
|
||||||
|
aspectRatio: 1,
|
||||||
|
viewMode: 1,
|
||||||
|
autoCropArea: 0.85,
|
||||||
|
responsive: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', function() {
|
||||||
|
if (cropper) { cropper.destroy(); cropper = null; }
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
fileInput.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', function() {
|
||||||
|
if (!cropper) return;
|
||||||
|
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
confirmBtn.textContent = i18nCropUploading;
|
||||||
|
showStatus(i18nCropUploading, 'gray');
|
||||||
|
|
||||||
|
var canvas = cropper.getCroppedCanvas({
|
||||||
|
width: 256,
|
||||||
|
height: 256,
|
||||||
|
maxWidth: 512,
|
||||||
|
maxHeight: 512,
|
||||||
|
imageSmoothingEnabled: true,
|
||||||
|
imageSmoothingQuality: 'high'
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.toBlob(function(blob) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('avatar', blob, 'avatar.jpg');
|
||||||
|
|
||||||
|
fetch('/profile/avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
.then(function(res) { return res.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.avatar) {
|
||||||
|
var ts = Date.now();
|
||||||
|
var avatarUrl = '/uploads/avatars/' + data.avatar + '?t=' + ts;
|
||||||
|
|
||||||
|
// Update profile page preview
|
||||||
|
previewContainer.innerHTML =
|
||||||
|
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
|
||||||
|
|
||||||
|
// Update nav bar avatar
|
||||||
|
var navAvatarBtn = document.getElementById('avatarBtn');
|
||||||
|
if (navAvatarBtn) {
|
||||||
|
navAvatarBtn.innerHTML =
|
||||||
|
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus(i18nCropSuccess, 'green');
|
||||||
|
} else {
|
||||||
|
showStatus(data.error || i18nCropError, 'red');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
showStatus(i18nCropError, 'red');
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
|
if (cropper) { cropper.destroy(); cropper = null; }
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
fileInput.value = '';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
confirmBtn.textContent = i18nCropConfirm;
|
||||||
|
});
|
||||||
|
}, 'image/jpeg', 0.92);
|
||||||
|
});
|
||||||
|
|
||||||
|
function showStatus(msg, color) {
|
||||||
|
statusDiv.textContent = msg;
|
||||||
|
statusDiv.classList.remove('hidden', 'text-gray-500', 'text-green-600', 'text-red-600');
|
||||||
|
if (color === 'green') statusDiv.classList.add('text-green-600');
|
||||||
|
else if (color === 'red') statusDiv.classList.add('text-red-600');
|
||||||
|
else statusDiv.classList.add('text-gray-500');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user