Files
lolisland.us/src/components/UserDropdown.astro

238 lines
6.2 KiB
Plaintext

---
import { getLangFromUrl, getTranslations } from "../i18n/utils";
const lang = getLangFromUrl(Astro.url);
const t = getTranslations(lang);
---
<div class="user-dropdown hidden">
<button class="user-avatar-btn" aria-label="User Menu">
<img src="" alt="User Avatar" class="user-avatar-img">
</button>
<div class="dropdown-menu">
<button class="menu-item logout-btn">{t("user.logout")}</button>
</div>
</div>
<script>
import { BACKEND_API_BASE } from "../config/loginApiBaseUrl";
// Wrap in astro:page-load to support View Transitions
document.addEventListener("astro:page-load", () => {
const userDropdown = document.querySelector(
".user-dropdown",
) as HTMLElement;
// If component is not on page, exit
if (!userDropdown) return;
// Scope selectors to this component instance
const avatarImg = userDropdown.querySelector(
".user-avatar-img",
) as HTMLImageElement;
const avatarBtn = userDropdown.querySelector(
".user-avatar-btn",
) as HTMLButtonElement;
const dropdownMenu = userDropdown.querySelector(
".dropdown-menu",
) as HTMLElement;
const logoutBtn = userDropdown.querySelector(
".logout-btn",
) as HTMLButtonElement;
async function updateAvatar() {
renderAvatar();
await refreshUserInfo();
renderAvatar();
}
function renderAvatar() {
const avatar = localStorage.getItem("avatar");
const token = localStorage.getItem("token");
if (token) {
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;
}
avatarImg.onerror = () => {
avatarImg.src =
"https://ui-avatars.com/api/?name=User&background=random";
avatarImg.onerror = null;
};
} else {
// 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 {
userDropdown.classList.add("hidden");
}
}
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();
// Listen for login success event
window.addEventListener("login-success", updateAvatar);
// Toggle Dropdown
if (avatarBtn) {
avatarBtn.addEventListener("click", (e) => {
e.stopPropagation();
dropdownMenu.classList.toggle("active");
});
}
// Close dropdown when clicking outside
document.addEventListener("click", (e) => {
if (userDropdown && !userDropdown.contains(e.target as Node)) {
dropdownMenu.classList.remove("active");
}
});
// Logout
if (logoutBtn) {
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
window.dispatchEvent(new CustomEvent("logout-success"));
});
}
});
</script>
<style>
.user-dropdown {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.hidden {
/* biome-ignore lint/complexity/noImportantStyles: utility class */
display: none !important;
}
.user-avatar-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
border-radius: 50%;
overflow: hidden;
width: 32px;
height: 32px;
border: 2px solid rgba(255, 255, 255, 0.2);
transition: border-color 0.2s;
box-sizing: border-box;
}
.user-avatar-btn:hover {
border-color: rgba(255, 255, 255, 0.8);
}
.user-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 10px;
background: rgba(15, 15, 15, 0.75); /* Dark semi-transparent */
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 0.6rem;
width: max-content;
min-width: 100px; /* Optional: keep a small min-width if desired, or remove */
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 1001;
display: flex;
flex-direction: column;
gap: 0.5rem;
opacity: 0;
pointer-events: none;
transform: translateY(10px);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.dropdown-menu.active {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.menu-item {
display: flex;
align-items: center;
justify-content: center; /* Center text */
width: 100%;
white-space: nowrap; /* Prevent wrapping */
padding: 0.8rem 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1); /* Subtle border */
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0.02)
);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.2s ease;
}
.menu-item:hover {
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0.06)
);
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.2);
}
</style>