mirror of
https://github.com/101island/lolisland.us.git
synced 2026-03-01 03:49:42 +08:00
Merge branch 'dev'
This commit is contained in:
8345
package-lock.json
generated
Normal file
8345
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -56,5 +56,3 @@ export const MARBLE_CONFIG = {
|
||||
enable: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const AVATAR_BASE_URL = "https://avatar.awfufu.com/qq/";
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user