Files
lolisland.us/src/components/central-island/DashboardView.astro

505 lines
13 KiB
Plaintext

---
import { getLangFromUrl, getTranslations } from "../../i18n/utils";
const lang = getLangFromUrl(Astro.url);
const t = getTranslations(lang);
---
<div id="view-dashboard" class="login-window fade-in dashboard-view hidden">
<button class="close-btn" data-action="close">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<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>
<!-- 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>
<!-- 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");
const input = document.getElementById(
"avatar-upload-input",
) as HTMLInputElement;
const img = document.getElementById(
"dashboard-avatar-img",
) as HTMLImageElement;
// 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();
});
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;
// 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; // Should handle error
const formData = new FormData();
formData.append("avatar", blob, "avatar.webp");
try {
const res = await fetch(`${BACKEND_API_BASE}/user/avatar`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (res.ok) {
const { key } = (await res.json()) as { key: string };
if (key) {
localStorage.setItem("avatar", key);
if (img) img.src = `${BACKEND_API_BASE}/user/avatar/${key}`;
}
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("Upload failed", e);
alert("Upload failed");
loadingView.classList.add("hidden");
cropperView.classList.remove("hidden");
(btnConfirm as HTMLButtonElement).disabled = false;
}
};
btnConfirm.addEventListener("click", () => {
if (!cropper) return;
// biome-ignore lint/suspicious/noExplicitAny: Missing type definition for getCroppedCanvas
const canvas = (cropper as any).getCroppedCanvas({
width: 640,
height: 640,
imageSmoothingQuality: "high",
});
if (!canvas) return;
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);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
}
.change-avatar-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.change-avatar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.hidden {
/* biome-ignore lint/complexity/noImportantStyles: utility class */
display: none !important;
}
.login-window {
padding: 2rem 2.4rem;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0.04)
);
backdrop-filter: blur(6px);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.08),
0 10px 40px rgba(0, 0, 0, 0.5);
position: relative;
overflow: hidden;
text-align: center;
width: 640px; /* Doubled width */
max-width: 90vw;
pointer-events: auto;
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
padding: 4px;
border-radius: 50%;
transition:
background 0.2s,
color 0.2s;
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);
color: white;
}
h2 {
margin: 0 0 1.5rem 0;
font-size: 1.8rem;
color: #f7f7ff; /* var(--text) */
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.instruction {
color: rgba(255, 255, 255, 0.8);
margin-bottom: 1rem;
font-size: 0.9rem;
}
.dashboard-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 3rem;
padding-bottom: 3rem;
}
.dashboard-avatar {
width: 128px;
height: 128px;
border-radius: 32px;
overflow: hidden;
margin: 0 auto 1.5rem auto;
border: 3px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.dashboard-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.fade-in {
animation: fadeIn 0.5s ease forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
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>