feat: implement avatar cropping workflow with client-side compression and async loading UI

This commit is contained in:
2025-12-29 14:54:03 +08:00
parent 9275c4dabd
commit ae684ce5c8
4 changed files with 8673 additions and 35 deletions

8345
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"astro": "^5.0.0",
"cropperjs": "^1.6.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"typescript": "^5.9.3"

View File

@@ -22,21 +22,51 @@ const t = getTranslations(lang);
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div class="dashboard-avatar">
<img id="dashboard-avatar-img" src="" alt="User Avatar">
<div id="dashboard-content">
<div class="dashboard-avatar">
<img id="dashboard-avatar-img" src="" alt="User Avatar">
</div>
<input type="file" id="avatar-upload-input" accept="image/*" class="hidden">
<button id="btn-change-avatar" class="change-avatar-btn">
{t("dashboard.change_avatar")}
</button>
<h2>{t("dashboard.logged_in")}</h2>
<p class="instruction">{t("dashboard.instruction")}</p>
</div>
<input type="file" id="avatar-upload-input" accept="image/*" class="hidden">
<button id="btn-change-avatar" class="change-avatar-btn">
{t("dashboard.change_avatar")}
</button>
<!-- Cropper View (Replaces content) -->
<div id="cropper-view" class="hidden">
<div class="cropper-container">
<img id="cropper-image" src="" alt="Crop Preview">
</div>
<div class="cropper-actions">
<button id="btn-cancel-crop" class="secondary-btn">
{t("common.cancel")}
</button>
<button
id="btn-confirm-crop"
class="primary-btn"
data-text={t("common.confirm")}
>
{t("common.confirm")}
</button>
</div>
</div>
<h2>{t("dashboard.logged_in")}</h2>
<p class="instruction">{t("dashboard.instruction")}</p>
<!-- Loading View -->
<div id="loading-view" class="hidden loading-view">
<h2 id="loading-title">{t("common.loading")}</h2>
<div class="spinner"></div>
</div>
</div>
<script>
import Cropper from "cropperjs";
import { BACKEND_API_BASE } from "../../config/loginApiBaseUrl";
import "cropperjs/dist/cropper.css";
document.addEventListener("astro:page-load", () => {
const btnChange = document.getElementById("btn-change-avatar");
@@ -47,69 +77,249 @@ const t = getTranslations(lang);
"dashboard-avatar-img",
) as HTMLImageElement;
if (btnChange && input) {
// View Containers
const dashboardContent = document.getElementById("dashboard-content");
const cropperView = document.getElementById("cropper-view");
const loadingView = document.getElementById("loading-view");
const loadingTitle = document.getElementById("loading-title");
// Cropper Elements
const cropperImage = document.getElementById(
"cropper-image",
) as HTMLImageElement;
const btnCancel = document.getElementById("btn-cancel-crop");
const btnConfirm = document.getElementById("btn-confirm-crop");
// Translations
const loadingText =
document.documentElement.lang === "zh-cn"
? "加载中..."
: document.documentElement.lang === "zh-hk"
? "載入中..."
: document.documentElement.lang === "ja"
? "読み込み中..."
: "Loading...";
const processingText =
document.documentElement.lang === "zh-cn"
? "处理中..."
: document.documentElement.lang === "zh-hk"
? "處理中..."
: document.documentElement.lang === "ja"
? "処理中..."
: "Processing...";
let cropper: Cropper | null = null;
if (
btnChange &&
input &&
dashboardContent &&
cropperView &&
loadingView &&
loadingTitle &&
cropperImage &&
btnCancel &&
btnConfirm
) {
btnChange.addEventListener("click", () => {
input.click();
});
input.addEventListener("change", async () => {
const showLoading = (title: string) => {
if (loadingTitle) loadingTitle.textContent = title;
loadingView.classList.remove("hidden");
dashboardContent.classList.add("hidden");
cropperView.classList.add("hidden");
};
input.addEventListener("change", () => {
const file = input.files?.[0];
if (!file) return;
// Reset input value so same file can be selected again if needed
// but proceed first.
// Async Loading for large files
showLoading(loadingText);
// Use setTimeout to allow UI to render loading state
setTimeout(() => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
loadingView.classList.add("hidden");
cropperView.classList.remove("hidden");
// Reset button state
const originalText = btnConfirm.getAttribute("data-text");
if (originalText) btnConfirm.textContent = originalText;
(btnConfirm as HTMLButtonElement).disabled = false;
// Load image first to calculate dimensions
cropperImage.onload = () => {
const containerWidth =
(document.querySelector(".cropper-container") as HTMLElement)
?.offsetWidth || 640;
if (cropperImage.naturalHeight > 0) {
const aspect =
cropperImage.naturalWidth / cropperImage.naturalHeight;
let targetHeight = containerWidth / aspect;
const maxHeight = window.innerHeight * 0.6; // Max 60vh
if (targetHeight > maxHeight) targetHeight = maxHeight;
if (targetHeight < 200) targetHeight = 200; // Min height
const container = document.querySelector(
".cropper-container",
) as HTMLElement;
if (container) {
container.style.height = `${targetHeight}px`;
}
}
if (cropper) cropper.destroy();
cropper = new Cropper(cropperImage, {
aspectRatio: 1,
viewMode: 1,
dragMode: "move",
autoCropArea: 1,
background: false, // Transparent background for PNGs
checkOrientation: false,
});
};
cropperImage.src = e.target.result as string;
}
};
reader.readAsDataURL(file);
}, 100);
});
const closeCropper = () => {
loadingView.classList.add("hidden");
cropperView.classList.add("hidden");
dashboardContent.classList.remove("hidden");
if (cropper) {
cropper.destroy();
cropper = null;
}
input.value = ""; // Reset input
};
btnCancel.addEventListener("click", closeCropper);
const compressAndUpload = async (blob: Blob) => {
showLoading(processingText);
const token = localStorage.getItem("token");
if (!token) return;
if (!token) return; // Should handle error
const formData = new FormData();
formData.append("avatar", file);
// Show loading state
const originalText = btnChange.textContent;
btnChange.textContent = "...";
(btnChange as HTMLButtonElement).disabled = true;
formData.append("avatar", blob, "avatar.webp");
try {
const res = await fetch(`${BACKEND_API_BASE}/user/avatar`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
},
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (res.ok) {
const { key } = (await res.json()) as { key: string };
if (key) {
localStorage.setItem("avatar", key);
// Update image source directly with new hash
if (img) {
img.src = `${BACKEND_API_BASE}/user/avatar/${key}`;
}
if (img) img.src = `${BACKEND_API_BASE}/user/avatar/${key}`;
}
// Dispatch event for other components (like navbar) if they listen
window.dispatchEvent(new CustomEvent("login-success"));
closeCropper();
} else {
alert("Upload failed");
// Return to cropper view on failure
loadingView.classList.add("hidden");
cropperView.classList.remove("hidden");
(btnConfirm as HTMLButtonElement).disabled = false;
}
} catch (e) {
console.error("Avatar upload failed", e);
console.error("Upload failed", e);
alert("Upload failed");
} finally {
btnChange.textContent = originalText;
(btnChange as HTMLButtonElement).disabled = false;
input.value = "";
loadingView.classList.add("hidden");
cropperView.classList.remove("hidden");
(btnConfirm as HTMLButtonElement).disabled = false;
}
};
btnConfirm.addEventListener("click", () => {
if (!cropper) return;
const canvas = cropper.getCroppedCanvas({
width: 640,
height: 640,
imageSmoothingQuality: "high",
});
if (!canvas) return;
// Show processing immediately before compression loop starts
// But compression loop is synchronous in JS (except toBlob which is callback based).
// Let's wrap compression in a small timeout too or just rely on async nature of toBlob?
// Canvas operations are sync. We should show loading before starting canvas ops.
// It's safer to show loading then setTimeout.
showLoading(processingText);
setTimeout(() => {
// Compression loop
let quality = 0.8;
const maxBytes = 100 * 1024; // 100KiB
const tryCompress = (q: number) => {
canvas.toBlob(
(blob: Blob | null) => {
if (!blob) return;
if (blob.size > maxBytes && q > 0.1) {
// Try lower quality
tryCompress(q - 0.1);
} else {
// Good enough or min quality reached
compressAndUpload(blob);
}
},
"image/webp",
q,
);
};
tryCompress(quality);
}, 50);
});
}
});
</script>
<style>
/* Loading View */
.loading-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
}
.spinner {
width: 40px;
height: 40px;
margin-top: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Existing Styles */
.change-avatar-btn {
margin-bottom: 1.5rem;
background: rgba(255, 255, 255, 0.1);
@@ -176,6 +386,7 @@ const t = getTranslations(lang);
display: flex;
align-items: center;
justify-content: center;
z-index: 60; /* Higher z-index to stay above cropper if needed */
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.1);
@@ -231,4 +442,69 @@ const t = getTranslations(lang);
transform: translateY(0);
}
}
/* Cropper View Styles */
#cropper-view {
/* Replaces content, so standard flow */
width: 100%;
display: flex;
flex-direction: column;
align-items: center; /* Center content */
}
.cropper-container {
width: 100%;
/* Height is set dynamically by JS */
/* background: #000; Removed to prevent black bars */
overflow: hidden;
margin-bottom: 1rem;
background: transparent;
}
/* Limit image max size to container */
#cropper-image {
display: block;
max-width: 100%;
}
.cropper-actions {
display: flex;
gap: 1rem;
justify-content: center; /* Center buttons */
width: 100%;
}
.primary-btn,
.secondary-btn {
padding: 8px 20px;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
border: none;
transition: all 0.2s;
}
.primary-btn {
background: #fff;
color: #000;
}
.primary-btn:hover {
background: #e0e0e0;
}
.primary-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.secondary-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
}
.secondary-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -62,7 +62,11 @@ export const ui = {
"settings.motion": "Motion",
"settings.orientation": "Orientation",
"settings.background": "Background",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"user.logout": "Logout",
"common.loading": "Loading...",
"common.processing": "Processing...",
},
"zh-cn": {
"site.title": "Lolisland",
@@ -118,6 +122,10 @@ export const ui = {
"settings.orientation": "方向感应",
"settings.background": "背景",
"user.logout": "退出登录",
"common.cancel": "取消",
"common.confirm": "确认",
"common.loading": "加载中...",
"common.processing": "处理中...",
},
"zh-hk": {
"site.title": "Lolisland",
@@ -173,6 +181,10 @@ export const ui = {
"settings.orientation": "方向感應",
"settings.background": "背景",
"user.logout": "登出",
"common.cancel": "取消",
"common.confirm": "確認",
"common.loading": "載入中...",
"common.processing": "處理中...",
},
ja: {
"site.title": "Lolisland",
@@ -229,5 +241,9 @@ export const ui = {
"settings.orientation": "オリエンテーション",
"settings.background": "背景",
"user.logout": "ログアウト",
"common.cancel": "キャンセル",
"common.confirm": "確認",
"common.loading": "読み込み中...",
"common.processing": "処理中...",
},
} as const;