Merge branch 'dev'

This commit is contained in:
2025-12-29 16:15:53 +08:00
13 changed files with 9029 additions and 114 deletions

8345
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,13 +14,16 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/cloudflare": "^12.6.12",
"@astrojs/react": "^4.4.2",
"@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"
"react-dom": "^19.2.3",
"typescript": "^5.9.3"
},
"devDependencies": {
"@biomejs/biome": "2.3.8",

View File

@@ -21,7 +21,7 @@ const { titleSvg } = Astro.props;
<script>
import { BACKEND_API_BASE } from "../config/loginApiBaseUrl";
import { defaultLang, languages, ui } from "../i18n/ui";
import { defaultLang, ui } from "../i18n/ui";
// Helpers that don't depend on DOM immediately
const getT = () => {
@@ -45,6 +45,8 @@ const { titleSvg } = Astro.props;
// Actually, let's keep state here.
let isLoggedIn = false;
let currentQQ = "";
let currentAvatar = "";
let currentUser = "";
let isPolling = false;
// DOM Helpers (Dynamic)
@@ -128,21 +130,49 @@ const { titleSvg } = Astro.props;
function updateDashboard() {
const displays = getDisplays();
if (displays.dashAvatar) {
if (currentQQ) {
displays.dashAvatar.src = `https://q.qlogo.cn/headimg_dl?dst_uin=${currentQQ}&spec=640&img_type=jpg`;
if (currentAvatar && /^[0-9a-f]{16}$/.test(currentAvatar)) {
displays.dashAvatar.src = `${BACKEND_API_BASE}/user/avatar/${currentAvatar}`;
displays.dashAvatar.onerror = () => {
// Fallback to UI Avatars if hash fails (shouldn't happen if valid, but maybe network error)
displays.dashAvatar.src =
"https://ui-avatars.com/api/?name=" +
(currentUser || "User") +
"&background=random";
displays.dashAvatar.onerror = null;
};
} else {
// Fallback or default
displays.dashAvatar.src =
"https://ui-avatars.com/api/?name=User&background=random";
"https://ui-avatars.com/api/?name=" +
(currentUser || "User") +
"&background=random";
}
}
}
function handleLoginSuccess(token: string, qq: string) {
function handleLoginSuccess(token: string, qq: string, avatar: string) {
localStorage.setItem("token", token);
// Extract username from token
try {
const payload = JSON.parse(atob(token.split(".")[1]));
if (payload.user_id) {
currentUser = payload.user_id;
localStorage.setItem("username", currentUser);
}
} catch (e) {
console.error("Failed to decode token", e);
}
if (qq) {
localStorage.setItem("qq", String(qq));
currentQQ = String(qq);
}
if (avatar) {
localStorage.setItem("avatar", String(avatar));
currentAvatar = String(avatar);
}
isLoggedIn = true;
updateDashboard();
window.dispatchEvent(new CustomEvent("login-success"));
@@ -191,9 +221,14 @@ const { titleSvg } = Astro.props;
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: u, password: p }),
});
const data = await res.json();
const data = (await res.json()) as {
token?: string;
qq?: string;
avatar?: string;
error?: string;
};
if (data.token) {
handleLoginSuccess(data.token, data.qq);
handleLoginSuccess(data.token, data.qq || "", data.avatar || "");
if (inputs.loginUser) inputs.loginUser.value = "";
if (inputs.loginPass) inputs.loginPass.value = "";
} else {
@@ -227,7 +262,7 @@ const { titleSvg } = Astro.props;
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ qq }),
});
const data = await res.json();
const data = (await res.json()) as { code?: string; error?: string };
if (data.code) {
if (displays.verifyCode)
displays.verifyCode.textContent = `/login ${data.code}`;
@@ -257,14 +292,19 @@ const { titleSvg } = Astro.props;
if (!isPolling) return; // Check again after await
const data = await res.json();
const data = (await res.json()) as {
status: string;
token: string;
qq: string;
avatar: string;
};
if (data.status === "verified") {
isPolling = false;
switchView("register");
} else if (data.status === "authenticated") {
isPolling = false;
handleLoginSuccess(data.token, data.qq);
handleLoginSuccess(data.token, data.qq, data.avatar || "");
} else {
// Status pending or other, retry immediately
if (isPolling) poll();
@@ -306,16 +346,24 @@ const { titleSvg } = Astro.props;
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: u, password: p, qq: currentQQ }),
});
const data = await res.json();
const data = (await res.json()) as { error?: string };
if (res.ok) {
const loginRes = await fetch(`${BACKEND_API_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: u, password: p }),
});
const loginData = await loginRes.json();
const loginData = (await loginRes.json()) as {
token?: string;
qq?: string;
avatar?: string;
};
if (loginData.token) {
handleLoginSuccess(loginData.token, loginData.qq);
handleLoginSuccess(
loginData.token || "",
loginData.qq || "",
loginData.avatar || "",
);
} else {
switchView("login");
}
@@ -332,8 +380,40 @@ const { titleSvg } = Astro.props;
function checkLoginState() {
const token = localStorage.getItem("token");
const storedQQ = localStorage.getItem("qq");
const storedUser = localStorage.getItem("username");
const storedAvatar = localStorage.getItem("avatar");
isLoggedIn = !!token;
if (storedQQ) currentQQ = storedQQ;
if (storedUser) currentUser = storedUser;
if (storedAvatar) currentAvatar = storedAvatar;
if (isLoggedIn) {
// Sync user info to ensure local cache is up to date
fetch(`${BACKEND_API_BASE}/me`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json() as Promise<{ avatar?: string; qq?: string }>)
.then((data) => {
let needsUpdate = false;
if (data.avatar && data.avatar !== currentAvatar) {
currentAvatar = data.avatar;
localStorage.setItem("avatar", currentAvatar);
needsUpdate = true;
}
if (data.qq && data.qq !== currentQQ) {
currentQQ = data.qq;
localStorage.setItem("qq", currentQQ);
// Sync updated QQ if changed
currentQQ = data.qq;
localStorage.setItem("qq", currentQQ);
needsUpdate = true;
}
if (needsUpdate) {
updateDashboard();
}
})
.catch(console.error);
}
if (isLoggedIn) {
updateDashboard();
@@ -367,6 +447,8 @@ const { titleSvg } = Astro.props;
isLoggedIn = !!token;
const storedQQ = localStorage.getItem("qq");
if (storedQQ) currentQQ = storedQQ;
const storedAvatar = localStorage.getItem("avatar");
if (storedAvatar) currentAvatar = storedAvatar;
updateDashboard();
if (isLoggedIn) switchView("dashboard");
});
@@ -376,6 +458,8 @@ const { titleSvg } = Astro.props;
currentQQ = "";
localStorage.removeItem("token");
localStorage.removeItem("qq");
localStorage.removeItem("avatar");
currentAvatar = "";
switchView("title");
});
}

View File

@@ -161,6 +161,19 @@ const currentLabel = languages[lang as keyof typeof languages] || lang;
opacity: 0.7;
}
.item-flag {
font-size: 1.2rem;
border-radius: 2px;
display: block;
}
.item-text {
font-weight: 500;
font-size: 0.95rem;
white-space: nowrap;
line-height: 1;
}
.menu-item.loading {
justify-content: center;
}
@@ -239,17 +252,4 @@ const currentLabel = languages[lang as keyof typeof languages] || lang;
color: #7cfbff;
border: 1px solid rgba(124, 251, 255, 0.2);
}
.item-flag {
font-size: 1.2rem;
border-radius: 2px;
display: block;
}
.item-text {
font-weight: 500;
font-size: 0.95rem;
white-space: nowrap;
line-height: 1;
}
</style>

View File

@@ -20,16 +20,47 @@ import Particles from "./Particles.astro";
</div>
<script>
import { BACKEND_API_BASE } from "../config/loginApiBaseUrl";
import { fetchUsers } from "../data/users";
import { MarbleSystem } from "../utils/marbleSystem";
declare global {
interface Window {
hasInitializedMarbleSystem: boolean;
marbleSystemInstance: unknown;
marbleSystemInstance: MarbleSystem; // Better typing if possible, or keep unknown and cast
}
}
// Marble Update Listener
window.addEventListener("login-success", () => {
const sys = window.marbleSystemInstance;
if (!sys) return;
const currentUsername = localStorage.getItem("username");
const newAvatar = localStorage.getItem("avatar");
if (currentUsername && newAvatar && /^[0-9a-f]{16}$/.test(newAvatar)) {
// Find marble by label (username) using getMarbles()
const marbles = sys.getMarbles();
const userMarble = marbles.find((m) => {
// We need to find the label element text
const label = m.node.querySelector(".marble-label");
return label && label.textContent === currentUsername;
});
if (userMarble) {
const innerMarble = userMarble.node.querySelector(
".marble",
) as HTMLElement;
if (innerMarble) {
innerMarble.style.backgroundImage = `url("${BACKEND_API_BASE}/user/avatar/${newAvatar}")`;
// Also update the id if the system relies on it for something else
userMarble.id = newAvatar;
}
}
}
});
// Prevent re-initialization on View Transitions
if (!window.hasInitializedMarbleSystem) {
window.hasInitializedMarbleSystem = true;
@@ -47,17 +78,17 @@ import Particles from "./Particles.astro";
if (field) {
let zoomLevel = 1.0;
// 初始化弹珠系统
// Initialize marble system
const marbleSystem = new MarbleSystem({
container: field,
fieldWidth: window.innerWidth,
fieldHeight: window.innerHeight,
});
// 启动动画循环
// Start animation loop
marbleSystem.start();
// 获取并添加用户弹珠
// Fetch and add user marbles
fetchUsers()
.then((users) => {
marbleSystem.addMarbles(users);
@@ -110,7 +141,7 @@ import Particles from "./Particles.astro";
updateDebug();
}
// 缩放功能 - Button Logic handled outside to support re-rendering
// Zoom function - Button Logic handled outside to support re-rendering
// Toggle Collisions
window.addEventListener("toggle-collision", ((e: CustomEvent) => {
@@ -135,10 +166,20 @@ import Particles from "./Particles.astro";
}) as EventListener);
// Toggle Title
let titleTimeout: number;
window.addEventListener("toggle-title", ((
_e: CustomEvent,
) => {}) as EventListener);
window.addEventListener("toggle-title", ((e: CustomEvent) => {
const titleView = document.getElementById("view-title");
if (titleView) {
console.log("Toggle Title:", e.detail.enabled);
if (e.detail.enabled) {
// Restore visibility if enabled, otherwise force hide using display: none
titleView.style.display = "";
titleView.style.opacity = "1"; // Ensure opacity is restored if we messed with it
titleView.style.pointerEvents = "auto";
} else {
titleView.style.display = "none";
}
}
}) as EventListener);
// Toggle Marbles
let marbleTimeout: number;
@@ -201,24 +242,29 @@ import Particles from "./Particles.astro";
// Re-attach Zoom Controls
const zoomInBtn = document.getElementById("zoom-in");
const zoomOutBtn = document.getElementById("zoom-out");
const zoomLabel = document.getElementById("zoom-level");
const sys = window.marbleSystemInstance;
let currentZoom = 1.0;
const updateZoomLabel = () => {
if (zoomLabel) {
zoomLabel.textContent = `${currentZoom.toFixed(1)}x`;
}
};
if (sys) {
if (zoomInBtn) {
zoomInBtn.addEventListener("click", () => {
// Since we can't easily access the closure variable 'zoomLevel' here without exposing it,
// for now we trust the system or would need to refactor. The user just wants persistence.
// Re-implementing basic zoom logic using the system instance if possible?
// Actually, let's just use a hardcoded small step, relying on internal state if we exposed it.
// But MarbleSystem probably doesn't expose current zoom.
// It's acceptable to skip deep logic here for the 'persist' fix unless user complains about zoom breaking after Nav.
// Let's at least prevent errors.
console.log("Zoom In (Navigation Persist)");
currentZoom = Math.min(currentZoom + 0.1, 2.0);
sys.setZoom(currentZoom);
updateZoomLabel();
});
}
if (zoomOutBtn) {
zoomOutBtn.addEventListener("click", () => {
console.log("Zoom Out (Navigation Persist)");
currentZoom = Math.max(currentZoom - 0.1, 0.5);
sys.setZoom(currentZoom);
updateZoomLabel();
});
}
}
@@ -331,10 +377,7 @@ import Particles from "./Particles.astro";
:global(.marble-wrapper) {
position: absolute;
will-change: transform, width, height;
transition:
opacity 1s ease,
width 0.3s ease,
height 0.3s ease;
transition: opacity 1s ease;
}
:global(.marble-wrapper:hover) {

View File

@@ -39,6 +39,7 @@ const t = getTranslations(lang);
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<span id="zoom-level">1.0x</span>
</div>
<div class="separator"></div>
@@ -251,6 +252,13 @@ const t = getTranslations(lang);
});
}
// Initial State Check
const state = window._centralIslandState;
if (state && state.currentView !== "title" && toggles.title) {
toggles.title.disabled = true;
toggles.title.parentElement?.classList.add("disabled");
}
// Update global reference to fresh DOM elements. Global listeners (defined below) use this to access current toggles without accumulating duplicate handlers.
window._settingsToggles = toggles;
});
@@ -258,37 +266,21 @@ const t = getTranslations(lang);
// Defines global listeners ONCE
if (!window._settingsListenersAttached) {
window._settingsListenersAttached = true;
let titlePrevState = true;
window.addEventListener("open-login", () => {
// Listen for view changes to disable/enable Title toggle
window.addEventListener("view-change", ((e: CustomEvent) => {
const toggles = window._settingsToggles;
if (toggles?.title) {
titlePrevState = toggles.title.checked;
toggles.title.checked = false;
toggles.title.disabled = true;
const isTitleView = e.detail.view === "title";
toggles.title.disabled = !isTitleView;
const parent = toggles.title.parentElement;
if (parent) parent.classList.add("disabled");
if (parent) {
if (isTitleView) parent.classList.remove("disabled");
else parent.classList.add("disabled");
}
}
});
window.addEventListener("close-login", () => {
const toggles = window._settingsToggles;
if (toggles?.title) {
toggles.title.disabled = false;
const parent = toggles.title.parentElement;
if (parent) parent.classList.remove("disabled");
toggles.title.checked = titlePrevState;
// Use helper? We don't have access to 'emit'. define it or use dispatch.
window.dispatchEvent(
new CustomEvent("toggle-title", {
detail: { enabled: titlePrevState },
}),
);
}
});
}) as EventListener);
}
</script>
@@ -334,6 +326,16 @@ const t = getTranslations(lang);
display: flex;
flex-direction: row;
gap: 0.8rem;
align-items: center; /* Center label vertically */
}
#zoom-level {
color: rgba(255, 255, 255, 0.9);
font-size: 0.9rem;
font-weight: 500;
min-width: 3.5ch;
text-align: center;
font-variant-numeric: tabular-nums; /* Fixed width numbers to prevent jitter */
}
/* Button Styles (Shared) */

View File

@@ -40,34 +40,33 @@ const t = getTranslations(lang);
) as HTMLButtonElement;
async function updateAvatar() {
let qq = localStorage.getItem("qq");
renderAvatar();
await refreshUserInfo();
renderAvatar();
}
function renderAvatar() {
const avatar = localStorage.getItem("avatar");
const token = localStorage.getItem("token");
if (token) {
if (!qq) {
try {
// Fetch user info from backend if token exists but QQ is missing
const res = await fetch(`${BACKEND_API_BASE}/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
if (data.qq) {
qq = String(data.qq);
localStorage.setItem("qq", qq);
}
}
} catch (e) {
console.error("Failed to fetch user info", e);
if (avatar && /^[0-9a-f]{16}$/.test(avatar)) {
// Basic update
const newSrc = `${BACKEND_API_BASE}/user/avatar/${avatar}`;
if (avatarImg.src !== newSrc) {
avatarImg.src = newSrc;
}
}
if (qq) {
avatarImg.src = `https://q.qlogo.cn/headimg_dl?dst_uin=${qq}&spec=640&img_type=jpg`;
avatarImg.onerror = () => {
avatarImg.src =
"https://ui-avatars.com/api/?name=User&background=random";
avatarImg.onerror = null;
};
} else {
// Default avatar or placeholder if QQ is missing and fetch failed
avatarImg.src =
"https://ui-avatars.com/api/?name=User&background=random";
// Default if no valid avatar in LS
if (!avatarImg.src.includes("ui-avatars.com")) {
avatarImg.src =
"https://ui-avatars.com/api/?name=User&background=random";
}
}
userDropdown.classList.remove("hidden");
} else {
@@ -75,6 +74,31 @@ const t = getTranslations(lang);
}
}
async function refreshUserInfo() {
const token = localStorage.getItem("token");
if (!token) return;
try {
const res = await fetch(`${BACKEND_API_BASE}/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = (await res.json()) as {
qq?: string;
avatar?: string;
};
if (data.qq) {
localStorage.setItem("qq", String(data.qq));
}
if (data.avatar) {
localStorage.setItem("avatar", String(data.avatar));
}
}
} catch (e) {
console.error("Failed to fetch user info", e);
}
}
// Initial check
updateAvatar();
@@ -101,6 +125,7 @@ const t = getTranslations(lang);
logoutBtn.addEventListener("click", () => {
localStorage.removeItem("token");
localStorage.removeItem("qq");
localStorage.removeItem("avatar");
userDropdown.classList.add("hidden");
dropdownMenu.classList.remove("active");
// Notify other components if necessary, e.g. show login button again

View File

@@ -22,14 +22,323 @@ 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>
<!-- 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>
<h2>{t("dashboard.logged_in")}</h2>
<p class="instruction">{t("dashboard.instruction")}</p>
</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;
@@ -71,6 +380,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);
@@ -99,12 +409,12 @@ const t = getTranslations(lang);
padding-bottom: 3rem;
}
.dashboard-avatar {
width: 96px;
height: 96px;
border-radius: 24px;
width: 128px;
height: 128px;
border-radius: 32px;
overflow: hidden;
margin-bottom: 1.5rem;
border: 2px solid rgba(255, 255, 255, 0.2);
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 {
@@ -126,4 +436,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

@@ -56,5 +56,3 @@ export const MARBLE_CONFIG = {
enable: true,
},
} as const;
export const AVATAR_BASE_URL = "https://avatar.awfufu.com/qq/";

View File

@@ -1,8 +1,9 @@
// User data: Fetch user list from API and store
import { BACKEND_API_BASE } from "../config/loginApiBaseUrl";
import type { UserEntry } from "../config/marbleConfig";
export const USER_DATA_API = "https://avatar.awfufu.com/users";
export const USER_DATA_API = `${BACKEND_API_BASE}/users`;
// Fetch user data from API
export async function fetchUsers(): Promise<UserEntry[]> {

View File

@@ -43,6 +43,7 @@ export const ui = {
// Dashboard
"dashboard.logged_in": "Logged In",
"dashboard.change_avatar": "Change Avatar",
"dashboard.instruction": "Development in progress...",
// Verification
@@ -61,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",
@@ -98,6 +103,7 @@ export const ui = {
// Dashboard
"dashboard.logged_in": "已登录",
"dashboard.change_avatar": "修改头像",
"dashboard.instruction": "开发中...",
// Verification
@@ -116,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",
@@ -152,6 +162,7 @@ export const ui = {
// Dashboard
"dashboard.logged_in": "已登入",
"dashboard.change_avatar": "修改頭像",
"dashboard.instruction": "開發中...",
// Verification
@@ -170,6 +181,10 @@ export const ui = {
"settings.orientation": "方向感應",
"settings.background": "背景",
"user.logout": "登出",
"common.cancel": "取消",
"common.confirm": "確認",
"common.loading": "載入中...",
"common.processing": "處理中...",
},
ja: {
"site.title": "Lolisland",
@@ -206,6 +221,7 @@ export const ui = {
// Dashboard
"dashboard.logged_in": "ログイン中",
"dashboard.change_avatar": "アバター変更",
"dashboard.instruction": "開発中...",
// Verification
@@ -225,5 +241,9 @@ export const ui = {
"settings.orientation": "オリエンテーション",
"settings.background": "背景",
"user.logout": "ログアウト",
"common.cancel": "キャンセル",
"common.confirm": "確認",
"common.loading": "読み込み中...",
"common.processing": "処理中...",
},
} as const;

View File

@@ -1,7 +1,8 @@
// Marble factory: Responsible for creating and initializing marble instances
import { BACKEND_API_BASE } from "../config/loginApiBaseUrl";
import type { UserEntry } from "../config/marbleConfig";
import { AVATAR_BASE_URL, MARBLE_CONFIG } from "../config/marbleConfig";
import { MARBLE_CONFIG } from "../config/marbleConfig";
import type { Marble } from "./mouseInteraction";
export class MarbleFactory {
@@ -28,7 +29,7 @@ export class MarbleFactory {
// Generate avatar URL
private getAvatarUrl(id: string): string {
return `${AVATAR_BASE_URL}${id}`;
return `${BACKEND_API_BASE}/user/avatar/${id}`;
}
// Create marble DOM node wrapper
@@ -94,7 +95,7 @@ export class MarbleFactory {
try {
await img.decode();
} catch (e) {
} catch (_e) {
throw new Error(`Failed to load image: ${url}`);
}
@@ -104,8 +105,9 @@ export class MarbleFactory {
const physics = this.generateRandomPhysics(radius);
// Pre-set position to avoid flashing at 0,0
wrapper.style.transform = `translate(${physics.x - radius}px, ${physics.y - radius
}px)`;
wrapper.style.transform = `translate(${physics.x - radius}px, ${
physics.y - radius
}px)`;
const { massScale, massOffset } = MARBLE_CONFIG.physics;
const marble: Marble = {

View File

@@ -139,9 +139,26 @@ export class MarbleSystem {
}
private currentSubSteps: number = 1;
private currentZoomLevel: number = 1.0;
private targetZoomLevel: number = 1.0;
// Set target zoom level
public setZoom(level: number): void {
// Clamp constraints
this.targetZoomLevel = Math.max(0.2, Math.min(level, 3.0));
}
// Per-frame update logic
private update(dt: number): void {
// 1. Handle Smooth Zoom
if (Math.abs(this.targetZoomLevel - this.currentZoomLevel) > 0.001) {
// Lerp factor (adjust for speed)
const t = 1.0 - 0.1 ** dt; // Framerate independent lerp
this.currentZoomLevel +=
(this.targetZoomLevel - this.currentZoomLevel) * t;
this.updateMarbleSize(this.currentZoomLevel);
}
let subSteps = 1;
if (