diff --git a/component/auth.html b/component/auth.html
index ceca494c8f08d5bef14c4f8f9f51b4e6ca584273..ea74a90deeafd00bf8a86273f9ea045a96d60120 100644
--- a/component/auth.html
+++ b/component/auth.html
@@ -1,5 +1,5 @@
-
-
+
+
用户登录
@@ -20,12 +20,13 @@
我同意用户协议
-
+
+
+
+
-
-
-
+
+
+
diff --git a/component/footer.html b/component/footer.html
index 671de1b2d31de6cec3a6958e703f26f3293e4ec2..c5fa7c4b9bd4171dd1ad3aa05a479f9ddd1aba0b 100644
--- a/component/footer.html
+++ b/component/footer.html
@@ -1,3 +1,9 @@
\ No newline at end of file
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/component/header.html b/component/header.html
index 499f59c8389bac51a3cee8bc9a3c11834a96bb61..78713cac2c91886b299745692db860f0a580e74d 100644
--- a/component/header.html
+++ b/component/header.html
@@ -6,6 +6,7 @@
房间列表
排行榜
热门谱面
+ 联系我们
diff --git a/component/room-history.html b/component/room-history.html
new file mode 100644
index 0000000000000000000000000000000000000000..26fe8de9b9ddbc8a58a36e50570db246849ce663
--- /dev/null
+++ b/component/room-history.html
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/component/window.js b/component/window.js
index 9ab820e6abf165a2d504a2ca9102f142ad18c506..877be9ba46124d7d3774c7db1073725d47e3b3be 100644
--- a/component/window.js
+++ b/component/window.js
@@ -116,7 +116,7 @@ class MacWindow extends HTMLElement {
/* default content styling */
.content {
padding: 12px;
- height: calc(100% - 12px);
+ height: calc(100% - 12px - 40px);
overflow: auto;
}
@@ -125,16 +125,27 @@ class MacWindow extends HTMLElement {
padding: 0;
overflow: hidden; /* iframe handles its own scrolling */
}
- :host([embedded]) ::slotted(iframe) {
- width: 100%;
- height: 100%;
- border: 0;
- display: block;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: thin;
- }
- /* try to hide iframe element scrollbars in webkit (note: doesn't change inner page scrollbars) */
- :host([embedded]) ::slotted(iframe)::-webkit-scrollbar { display: none; }
+ :host([embedded]) ::slotted(iframe) {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ display: block;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: thin;
+ }
+ /* try to hide iframe element scrollbars in webkit (note: doesn't change inner page scrollbars) */
+ :host([embedded]) ::slotted(iframe)::-webkit-scrollbar { display: none; }
+ /* loading overlay appended as a slotted element */
+ :host([embedded]) ::slotted(.window-loader) {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0,0,0,0.28);
+ z-index: 5;
+ pointer-events: none;
+ }
/* small title style for demo */
::slotted(h2) {
@@ -145,6 +156,25 @@ class MacWindow extends HTMLElement {
}
::slotted(p) { color: rgba(255,255,255,0.85); margin: 0; }
+ .titlebar {
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px;
+ gap: 8px;
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+ background: linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.06));
+ z-index: 3;
+ }
+ .nav-left, .nav-right { display:flex; align-items:center; gap:6px; }
+ .nav-btn { background: rgba(255,255,255,0.04); border: none; color: #fff; padding:6px 8px; border-radius:6px; cursor:pointer }
+ .title-center { flex:1; display:flex; flex-direction:column; align-items:center; overflow:hidden }
+ .title-text { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600 }
+ .address-text { font-size:0.75rem; opacity:0.8; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:70% }
+ .open-link { color: #aee; text-decoration:none; padding:4px 6px; border-radius:6px; background: rgba(255,255,255,0.02) }
+ .resizer { position: absolute; right: 6px; bottom: 6px; width: 18px; height: 18px; cursor: se-resize; z-index: 4; border-radius:4px; background: rgba(255,255,255,0.03) }
+
@media (max-width: 720px) {
.stage { left: 50%; top: 50%; transform: translate(-50%, -50%) scale(1); }
.window { border-radius: 10px; }
@@ -157,10 +187,24 @@ class MacWindow extends HTMLElement {
`;
@@ -180,6 +224,9 @@ class MacWindow extends HTMLElement {
onKey: this._onKey.bind(this),
onBackdropClick: this._onBackdropClick.bind(this)
};
+ this._resizing = false;
+ this._currentIframe = null;
+ this._currentUrl = '';
}
connectedCallback() {
@@ -189,8 +236,21 @@ class MacWindow extends HTMLElement {
this._stage.addEventListener('pointermove', this._bound.onMove);
this._stage.addEventListener('pointerleave', this._bound.onLeave);
this._closeBtn.addEventListener('click', this._bound.onClose);
+ // support pointerdown for quicker response on touch devices
+ this._closeBtn.addEventListener('pointerdown', this._bound.onClose);
this._backdrop.addEventListener('click', this._bound.onBackdropClick);
+ // wire up titlebar controls
+ this._backBtn = this.shadowRoot.querySelector('.nav-btn.back');
+ this._forwardBtn = this.shadowRoot.querySelector('.nav-btn.forward');
+ this._openLinkAnchor = this.shadowRoot.querySelector('.open-link');
+ this._titleText = this.shadowRoot.querySelector('.title-text');
+ this._addressText = this.shadowRoot.querySelector('.address-text');
+ this._resizer = this.shadowRoot.querySelector('.resizer');
+ if (this._backBtn) this._backBtn.addEventListener('click', ()=> this._navigate(-1));
+ if (this._forwardBtn) this._forwardBtn.addEventListener('click', ()=> this._navigate(1));
+ if (this._resizer) this._resizer.addEventListener('pointerdown', this._onResizerDown.bind(this));
+
// keyboard
this.addEventListener('keydown', this._bound.onKey);
this.setAttribute('tabindex', '0');
@@ -207,7 +267,11 @@ class MacWindow extends HTMLElement {
this._stage.removeEventListener('pointermove', this._bound.onMove);
this._stage.removeEventListener('pointerleave', this._bound.onLeave);
this._closeBtn.removeEventListener('click', this._bound.onClose);
+ this._closeBtn.removeEventListener('pointerdown', this._bound.onClose);
this._backdrop.removeEventListener('click', this._bound.onBackdropClick);
+ if (this._backBtn) this._backBtn.removeEventListener('click', ()=> this._navigate(-1));
+ if (this._forwardBtn) this._forwardBtn.removeEventListener('click', ()=> this._navigate(1));
+ if (this._resizer) this._resizer.removeEventListener('pointerdown', this._onResizerDown);
this.removeEventListener('keydown', this._bound.onKey);
}
@@ -291,6 +355,69 @@ class MacWindow extends HTMLElement {
this._inner.style.transform = 'rotateX(0deg) rotateY(0deg) translateZ(0px)';
}
+ _onResizerDown(e){
+ e.preventDefault();
+ this._resizing = true;
+ const startX = e.clientX, startY = e.clientY;
+ const startRect = this._stage.getBoundingClientRect();
+ const startW = startRect.width;
+ const startH = startRect.height;
+ const onMove = (ev)=>{
+ const nw = Math.max(280, startW + (ev.clientX - startX));
+ const nh = Math.max(220, startH + (ev.clientY - startY));
+ this._stage.style.width = nw + 'px';
+ this._stage.style.height = nh + 'px';
+ };
+ const onUp = (ev)=>{
+ this._resizing = false;
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ // persist size
+ try{
+ if (this._currentUrl) {
+ window._macWindowSizeCache = window._macWindowSizeCache || {};
+ window._macWindowSizeCache[this._currentUrl] = window._macWindowSizeCache[this._currentUrl] || {};
+ const rect = this._stage.getBoundingClientRect();
+ window._macWindowSizeCache[this._currentUrl].w = Math.round(rect.width);
+ window._macWindowSizeCache[this._currentUrl].h = Math.round(rect.height);
+ }
+ }catch(e){}
+ };
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ }
+
+ _navigate(delta){
+ if (!this._currentIframe) return;
+ try {
+ if (delta < 0) this._currentIframe.contentWindow.history.back(); else this._currentIframe.contentWindow.history.forward();
+ } catch(e) {
+ try { this._currentIframe.contentWindow.postMessage({ type: 'hsn-navigate', delta }, '*'); } catch(e){}
+ }
+ }
+
+ registerIframe(iframe, url){
+ this._currentIframe = iframe;
+ this._currentUrl = url || '';
+ if (this._openLinkAnchor) this._openLinkAnchor.href = url || '';
+ const update = ()=>{
+ try{
+ const doc = iframe.contentDocument;
+ const title = doc && (doc.title || (doc.querySelector && doc.querySelector('h1') && doc.querySelector('h1').textContent)) || iframe.getAttribute('title') || '';
+ if (this._titleText) this._titleText.textContent = title || '窗口';
+ if (this._addressText) this._addressText.textContent = (iframe.contentWindow && iframe.contentWindow.location && iframe.contentWindow.location.href) || url || '';
+ }catch(e){
+ if (this._titleText) this._titleText.textContent = url || '窗口';
+ if (this._addressText) this._addressText.textContent = url || '';
+ }
+ try{
+ if (this._backBtn) this._backBtn.disabled = !(iframe.contentWindow && iframe.contentWindow.history && iframe.contentWindow.history.length>1);
+ }catch(e){}
+ };
+ iframe.addEventListener('load', ()=>{ update(); setTimeout(update,120); });
+ setTimeout(update,120);
+ }
+
_applyResponsiveSizing() {
// 移动端自适应
if (!this._stage) return;
diff --git a/css/style.css b/css/style.css
index 28a9953ac1f68d7d72708a4b598164c07ba0e3c1..52ee7adf3863670f62175a188df7a3450b7f5493 100644
--- a/css/style.css
+++ b/css/style.css
@@ -684,3 +684,84 @@
box-shadow: 0 6px 18px rgba(97,232,234,0.12);
transform: none;
}
+
+/* 统一表格组件样式(用于 rooms/ranks/top) */
+.table-card {
+ perspective: 1000px;
+ width: 95%;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+.table-card .table-scroll {
+ position: relative;
+ width: 100%;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* 将滚动作用于 table 本身以满足视觉需求 */
+table.responsive-table {
+ display: block; /* allow native horizontal scrolling on table */
+ width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ white-space: nowrap;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 12px;
+ backdrop-filter: blur(8px);
+ transform-style: preserve-3d;
+ transition: transform 0.18s ease;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
+ border: 1px solid rgba(255,255,255,0.14);
+}
+
+/* 固定列样式,避免在窄屏下表格列内换行导致宽度异常 */
+table.responsive-table th, table.responsive-table td {
+ white-space: nowrap;
+}
+
+/* 更合理的表格布局在小屏下取消 3D 位移并使用 fixed 布局 */
+@media (max-width: 768px) {
+ table.responsive-table {
+ transform: none !important;
+ animation: none !important;
+ table-layout: fixed;
+ }
+ table.responsive-table th, table.responsive-table td {
+ padding: 0.5rem 0.6rem;
+ font-size: 0.85rem;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+}
+
+/* 细窄的自定义滚动条(作用于 table 元素) */
+table.responsive-table::-webkit-scrollbar {
+ height: 8px;
+}
+table.responsive-table::-webkit-scrollbar-track {
+ background: rgba(255,255,255,0.03);
+ border-radius: 8px;
+}
+table.responsive-table::-webkit-scrollbar-thumb {
+ background: rgba(255,255,255,0.12);
+ border-radius: 8px;
+ border: 1px solid rgba(255,255,255,0.06);
+}
+/* Firefox */
+table.responsive-table { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.12) rgba(255,255,255,0.03); }
+
+/* 长文本滚动处理(用于谱面/长标题) */
+.scrolling-btn{ display:inline-block; max-width:150px; overflow:hidden; position:relative; vertical-align:middle; }
+.scrolling-btn > span{ display:inline-block; padding-right:24px; will-change:transform; }
+.scrolling-btn.marquee > span{ animation: scroll-left 8s linear infinite; }
+@keyframes scroll-left{
+ 0%{ transform: translateX(0); }
+ 100%{ transform: translateX(-100%); }
+}
+
+/* 当容器较窄时启用更温和的滚动 */
+@media (max-width: 768px){
+ .scrolling-btn{ max-width:120px; }
+ .scrolling-btn.marquee > span{ animation-duration:10s; }
+}
+
diff --git a/index.html b/index.html
index 68d78697d672d27a54b62997a434364241f17e54..b82735179c6d1d20b352203fe1b374ce104b56b2 100644
--- a/index.html
+++ b/index.html
@@ -11,19 +11,14 @@
-
-
-
-
-
-
+
+
+
+
操作成功!
diff --git a/js/agreement_update.js b/js/agreement_update.js
new file mode 100644
index 0000000000000000000000000000000000000000..6ec691642ce262434ca9d88a3cd28b3fe37ca95b
--- /dev/null
+++ b/js/agreement_update.js
@@ -0,0 +1,21 @@
+// Agreement update notifier
+(function(){
+ const KEY = '__hsn_last_agreement_v';
+ async function check(){
+ try{
+ const r = await fetch('/api/agreements/meta', {cache: 'no-store'});
+ if (!r.ok) return;
+ const d = await r.json();
+ const v = d.version || d.updated_at || '';
+ const prev = localStorage.getItem(KEY);
+ if (prev && v && prev !== v){
+ if (typeof showMessage === 'function'){
+ showMessage('服务条款更新', '服务条款已更新,请查看并同意', 'linear-gradient(135deg, rgba(255,192,32,0.08), rgba(255,96,96,0.06))', 0);
+ }
+ }
+ if (v) localStorage.setItem(KEY, v);
+ }catch(e){}
+ }
+ check();
+ setInterval(check, 1000*60*30);
+})();
diff --git a/js/auth-window.js b/js/auth-window.js
new file mode 100644
index 0000000000000000000000000000000000000000..97de41b6dd54875fe12586406d2d1df280af8e20
--- /dev/null
+++ b/js/auth-window.js
@@ -0,0 +1,136 @@
+(function(){
+ // Centralized auth window using mac-window
+ const state = { authMode: 'login', win: null };
+
+ function createWindowIfNeeded(){
+ if (state.win && document.body.contains(state.win)) return state.win;
+ // create a mac-window instance
+ const win = document.createElement('mac-window');
+ win.id = 'auth-mac-window';
+ // smaller default
+ win.style.setProperty('--width','420px');
+ win.style.setProperty('--height','380px');
+ document.body.appendChild(win);
+ state.win = win;
+
+ // when closed remove instance so next open creates fresh one
+ win.addEventListener('mac-window-close', () => {
+ try { win.remove(); } catch(e){}
+ state.win = null;
+ });
+
+ return win;
+ }
+
+ function openAuth(){
+ const tmpl = document.getElementById('auth-template');
+ if (!tmpl) return console.warn('auth template missing');
+ const win = createWindowIfNeeded();
+
+ // clear previous content
+ const existing = win.querySelector('#auth-box');
+ if (existing) existing.remove();
+
+ const node = tmpl.content.cloneNode(true);
+ // append nodes into window (slot area)
+ win.appendChild(node);
+
+ // wire up buttons
+ const submitBtn = win.querySelector('#auth-submit');
+ const toggleBtn = win.querySelector('#auth-toggle');
+ const msgElem = win.querySelector('#auth-msg');
+
+ if (submitBtn) submitBtn.addEventListener('click', submitAuth);
+ if (toggleBtn) toggleBtn.addEventListener('click', toggleAuthMode);
+
+ // ensure UI initial state
+ updateAuthUI();
+
+ win.open();
+ }
+
+ function closeAuth(){
+ if (state.win) state.win.close();
+ }
+
+ function toggleAuthMode(){
+ state.authMode = state.authMode === 'login' ? 'register' : 'login';
+ updateAuthUI();
+ }
+
+ function updateAuthUI(){
+ const win = state.win || document.getElementById('auth-mac-window');
+ const title = win && win.querySelector('#auth-title');
+ const phiraid = win && win.querySelector('#auth-phiraid');
+ const agreementContainer = win && win.querySelector('#agreement-container');
+ if (!title) return;
+ if (state.authMode === 'login'){
+ title.textContent = '用户登录';
+ if (phiraid) phiraid.classList.add('collapsed');
+ if (agreementContainer) agreementContainer.style.display = 'none';
+ } else {
+ title.textContent = '用户注册';
+ if (phiraid) phiraid.classList.remove('collapsed');
+ if (agreementContainer) agreementContainer.style.display = 'flex';
+ }
+ }
+
+ async function submitAuth(){
+ const win = state.win || document.getElementById('auth-mac-window');
+ if (!win) return;
+ const username = win.querySelector('#auth-name').value;
+ const password = win.querySelector('#auth-password').value;
+ const phira_id = win.querySelector('#auth-phiraid').value;
+ const remember = !!(win.querySelector('#remember-me') && win.querySelector('#remember-me').checked);
+ const agreeTerms = state.authMode === 'register' ? !!(win.querySelector('#agree-terms') && win.querySelector('#agree-terms').checked) : true;
+ const msg = win.querySelector('#auth-msg');
+ if (msg) msg.textContent = '处理中...';
+
+ try {
+ if (state.authMode === 'register' && !agreeTerms) {
+ if (msg) msg.textContent = '请同意用户协议';
+ return;
+ }
+ const endpoint = state.authMode === 'login' ? '/api/auth/login' : '/api/auth/users';
+ const payload = state.authMode === 'login' ? { username, password, remember } : { username, password, phira_id };
+ const res = await fetch(endpoint, {
+ method: 'POST',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify(payload)
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(()=>({}));
+ if (msg) msg.textContent = err.message || '操作失败';
+ return;
+ }
+ if (state.authMode === 'login'){
+ // refresh global currentUser if applicable
+ try {
+ const userRes = await fetch('/api/auth/me');
+ if (userRes.ok) {
+ window.currentUser = await userRes.json();
+ if (window.updateUserDisplay) try { window.updateUserDisplay(); } catch(e){}
+ }
+ } catch(e){}
+ }
+ if (msg) msg.textContent = state.authMode === 'login' ? '登录成功!' : '注册成功!';
+ setTimeout(()=>{
+ try { closeAuth(); } catch(e){}
+ if (state.authMode === 'register') state.authMode = 'login';
+ }, 800);
+ } catch (e) {
+ if (msg) msg.textContent = '网络错误';
+ console.error('auth error', e);
+ }
+ }
+
+ // expose globals used by header and other scripts
+ window.openAuth = openAuth;
+ window.closeAuth = closeAuth;
+ window.toggleAuthMode = toggleAuthMode;
+ window.submitAuth = submitAuth;
+ window.updateAuthUI = updateAuthUI;
+
+ // auto-initialize if template not yet loaded: wait for DOMContentLoaded
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ()=>{});
+})();
diff --git a/js/auto_update.js b/js/auto_update.js
new file mode 100644
index 0000000000000000000000000000000000000000..a8f53eadd20cf1510d5a6b453e3ec27d32f51fd1
--- /dev/null
+++ b/js/auto_update.js
@@ -0,0 +1,22 @@
+// Simple auto-update checker
+(function(){
+ const CHECK_INTERVAL = 1000 * 60 * 5; // 5 minutes
+ async function check(){
+ try{
+ const r = await fetch('/api/version', {cache: 'no-store'});
+ if (!r.ok) return;
+ const data = await r.json();
+ if (!window.__hsn_last_version) window.__hsn_last_version = data.version;
+ if (data.version && data.version !== window.__hsn_last_version){
+ window.__hsn_last_version = data.version;
+ if (typeof showMessage === 'function'){
+ showMessage('更新可用', '检测到新版本,建议刷新页面以获取最新内容', 'linear-gradient(135deg, rgba(32,160,255,0.12), rgba(0,200,120,0.08))', 0);
+ }
+ }
+ }catch(e){
+ // ignore
+ }
+ }
+ check();
+ setInterval(check, CHECK_INTERVAL);
+})();
diff --git a/js/contact-window.js b/js/contact-window.js
new file mode 100644
index 0000000000000000000000000000000000000000..e124983da715344a3ef2734556aa4bfa64161797
--- /dev/null
+++ b/js/contact-window.js
@@ -0,0 +1,44 @@
+// Contact window controller
+(function(){
+ async function openContact(){
+ const tplResp = await fetch('./component/contact.html');
+ const text = await tplResp.text();
+ const div = document.createElement('div');
+ div.innerHTML = text;
+ const tpl = div.querySelector('#contact-template');
+ if (!tpl) return;
+
+ const win = document.createElement('mac-window');
+ win.style.setProperty('--width','640px');
+ win.style.setProperty('--height','420px');
+ document.body.appendChild(win);
+
+ const content = tpl.content.cloneNode(true);
+ const slotWrap = document.createElement('div');
+ slotWrap.appendChild(content);
+ win.appendChild(slotWrap);
+
+ // simple carousel dummy: try to fetch announcements
+ try{
+ const r = await fetch('/api/announcements?limit=5');
+ if (r.ok){
+ const arr = await r.json();
+ const carousel = win.querySelector('#contact-carousel');
+ if (carousel && Array.isArray(arr)){
+ carousel.innerHTML = '';
+ arr.forEach(a=>{
+ const it = document.createElement('div');
+ it.className = 'contact-item';
+ it.textContent = a.title || a.summary || '公告';
+ carousel.appendChild(it);
+ });
+ }
+ }
+ }catch(e){}
+
+ // expose for global usage
+ window.openContact = openContact;
+ }
+
+ window.openContact = openContact;
+})();
diff --git a/js/message.js b/js/message.js
new file mode 100644
index 0000000000000000000000000000000000000000..6cddc4cedb87ebad0d5641db74ce6756d78515f3
--- /dev/null
+++ b/js/message.js
@@ -0,0 +1,70 @@
+// Unified message component
+(function(){
+ const containerId = 'hsn-message-container';
+ function ensureContainer(){
+ let c = document.getElementById(containerId);
+ if (!c){
+ c = document.createElement('div');
+ c.id = containerId;
+ c.style.position = 'fixed';
+ c.style.top = '12px';
+ c.style.right = '12px';
+ c.style.zIndex = 200000;
+ c.style.display = 'flex';
+ c.style.flexDirection = 'column';
+ c.style.gap = '8px';
+ document.body.appendChild(c);
+ }
+ return c;
+ }
+
+ function showMessage(title, content, bgColor, timeout=4000){
+ const c = ensureContainer();
+ const card = document.createElement('div');
+ card.style.minWidth = '220px';
+ card.style.maxWidth = '420px';
+ card.style.background = bgColor || 'rgba(255,255,255,0.06)';
+ card.style.backdropFilter = 'blur(12px)';
+ card.style.border = '1px solid rgba(255,255,255,0.08)';
+ card.style.borderRadius = '12px';
+ card.style.padding = '12px';
+ card.style.color = '#fff';
+ card.style.boxShadow = '0 8px 32px rgba(0,0,0,0.3)';
+ card.style.position = 'relative';
+
+ const close = document.createElement('button');
+ close.textContent = '×';
+ close.style.position = 'absolute';
+ close.style.right = '8px';
+ close.style.top = '8px';
+ close.style.border = 'none';
+ close.style.background = 'rgba(255,0,0,0.7)';
+ close.style.color = '#fff';
+ close.style.width = '20px';
+ close.style.height = '20px';
+ close.style.borderRadius = '50%';
+ close.style.cursor = 'pointer';
+ close.addEventListener('click', ()=>{ if (card.parentNode) card.parentNode.removeChild(card); });
+
+ const h = document.createElement('div');
+ h.textContent = title || '';
+ h.style.fontSize = '16px';
+ h.style.fontWeight = '700';
+ h.style.marginBottom = '6px';
+
+ const p = document.createElement('div');
+ p.innerHTML = content || '';
+ p.style.fontSize = '13px';
+
+ card.appendChild(close);
+ card.appendChild(h);
+ card.appendChild(p);
+ c.appendChild(card);
+
+ if (timeout > 0){
+ setTimeout(()=>{ try{ if (card.parentNode) card.parentNode.removeChild(card); }catch(e){} }, timeout);
+ }
+ }
+
+ window.showMessage = showMessage;
+})();
diff --git a/js/room-history.js b/js/room-history.js
new file mode 100644
index 0000000000000000000000000000000000000000..d20206f4b717d31e7884a818d481889f60d11eb9
--- /dev/null
+++ b/js/room-history.js
@@ -0,0 +1,58 @@
+// Room play-history window
+(function(){
+ async function openRoomHistory(roomId){
+ if (!roomId) { showMessage && showMessage('错误','未提供房间标识'); return; }
+
+ // load template
+ let tplText = '';
+ try{
+ const r = await fetch('./component/room-history.html');
+ tplText = await r.text();
+ }catch(e){ tplText = ''; }
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = tplText;
+ const tpl = wrapper.querySelector('#room-history-template');
+ if (!tpl) { showMessage && showMessage('错误','无法加载历史模板'); return; }
+
+ const win = document.createElement('mac-window');
+ win.style.setProperty('--width','680px');
+ win.style.setProperty('--height','420px');
+ document.body.appendChild(win);
+
+ const content = tpl.content.cloneNode(true);
+ const container = document.createElement('div');
+ container.appendChild(content);
+ win.appendChild(container);
+
+ const meta = win.querySelector('#history-meta');
+ const tbody = win.querySelector('#history-table tbody');
+ if (meta) meta.textContent = `房间:${roomId}`;
+
+ // try multiple endpoints
+ const candidates = [`/api/rooms/history/${encodeURIComponent(roomId)}`, `/api/rooms/history?room=${encodeURIComponent(roomId)}`];
+ let data = null;
+ for (const url of candidates){
+ try{
+ const r = await fetch(url);
+ if (!r.ok) continue;
+ data = await r.json();
+ break;
+ }catch(e){}
+ }
+
+ if (!Array.isArray(data) || data.length === 0){
+ tbody.innerHTML = '| 暂无历史记录 |
';
+ return;
+ }
+
+ tbody.innerHTML = data.map(item => {
+ const player = item.player_name || item.player || item.user || '匿名';
+ const chart = item.chart_name || item.chart || item.song || '未知';
+ const score = item.score != null ? item.score : (item.result || '—');
+ const t = item.time || item.ts || item.played_at || '';
+ return `| ${player} | ${chart} | ${score} | ${t} |
`;
+ }).join('');
+ }
+
+ window.openRoomHistory = openRoomHistory;
+})();
diff --git a/js/rooms.js b/js/rooms.js
index c6763e39d5055608a3ccae4cded9d4be2fe6526f..bd4fcbc92e6b0fbb9ce3c6ff5962d93bc8a468e8 100644
--- a/js/rooms.js
+++ b/js/rooms.js
@@ -149,13 +149,13 @@
const chartInfo = await getChartInfo(room.chart);
if (chartInfo) {
chartText = chartInfo.name
- ? `${chartInfo.name}`
+ ? `${chartInfo.name}`
: `ID: ${room.chart}`;
chartImg = chartInfo.illustration
? ``
: "无封面";
downloadBtn = chartInfo.file
- ? ``
+ ? ``
: "无文件";
}
}
@@ -164,6 +164,8 @@
const stateDisplay = `${stateInfo.text}`;
const hostBtn = ``;
const usersBtn = ``;
+ const roomIdVal = (room.id || room.name || '').toString().replace(/'/g, "\\'");
+ const historyBtn = ``;
const tr = document.createElement("tr");
tr.innerHTML = `
${room.name} |
@@ -175,6 +177,7 @@
${chartImg} |
${downloadBtn} |
${usersBtn} |
+ ${historyBtn} |
`;
tbody.appendChild(tr);
}
diff --git a/js/table-component.js b/js/table-component.js
new file mode 100644
index 0000000000000000000000000000000000000000..8f2baf9d98fe967c112899b02a2ef4ff56b3e26e
--- /dev/null
+++ b/js/table-component.js
@@ -0,0 +1,57 @@
+// Shared table helper: adds touch-friendly horizontal scroll behavior
+// and optional sticky header handling. Auto-initializes tables with class
+// "responsive-table".
+(function(){
+ function initTable(t){
+ if (t.__tableComponentInit) return; t.__tableComponentInit = true;
+ // make sure the table uses block display for native horizontal scroll
+ t.style.display = 'block';
+ t.style.overflowX = 'auto';
+ t.style.webkitOverflowScrolling = 'touch';
+
+ // add wheel-to-scroll-on-x for desktop mousewheel when shift not held
+ t.addEventListener('wheel', function(e){
+ if (Math.abs(e.deltaX) < Math.abs(e.deltaY)){
+ // translate vertical wheel to horizontal scroll for convenience
+ t.scrollLeft += e.deltaY;
+ e.preventDefault();
+ }
+ }, { passive: false });
+
+ // make header visually sticky by cloning header row into a fixed element if desired
+ // keep minimal: add shadow when scrolled horizontally to hint overflow
+ function updateShadow(){
+ if (t.scrollLeft > 0) t.style.boxShadow = 'inset 8px 0 12px -8px rgba(0,0,0,0.4)';
+ else t.style.boxShadow = '';
+ }
+ t.addEventListener('scroll', updateShadow);
+ updateShadow();
+
+ // accessibility: expose keyboard horizontal navigation
+ t.setAttribute('tabindex','0');
+ t.addEventListener('keydown', function(e){
+ if (e.key === 'ArrowRight') { t.scrollLeft += 60; e.preventDefault(); }
+ if (e.key === 'ArrowLeft') { t.scrollLeft -= 60; e.preventDefault(); }
+ if (e.key === 'Home') { t.scrollLeft = 0; }
+ if (e.key === 'End') { t.scrollLeft = t.scrollWidth; }
+ });
+ }
+
+ function scan(){
+ document.querySelectorAll('table.responsive-table').forEach(initTable);
+ // enable marquee for long scrolling-btn elements
+ setTimeout(()=>{
+ document.querySelectorAll('.scrolling-btn').forEach(el=>{
+ try{
+ const span = el.querySelector('span');
+ if (span && span.scrollWidth > el.clientWidth) el.classList.add('marquee');
+ }catch(e){}
+ });
+ }, 120);
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scan); else scan();
+
+ // expose for manual init
+ window.HSNTable = { init: initTable };
+})();
diff --git a/js/top.js b/js/top.js
index 1fc432fee7a82d488136ac7d4ed8f70c217d9446..222f1a3eab4977160707ca0f0e983a3ce4b752a8 100644
--- a/js/top.js
+++ b/js/top.js
@@ -216,12 +216,20 @@
// 谱面名称列
const nameTd = document.createElement("td");
- if (chartInfo && chartInfo.name) {
+ if (chartInfo && chartInfo.name) {
const nameBtn = document.createElement("a");
nameBtn.href = `https://phira.moe/chart/${chart.chart_id}`;
nameBtn.target = "_blank";
- nameBtn.className = "chart-name-btn";
- nameBtn.textContent = chartInfo.name;
+ nameBtn.className = "chart-name-btn scrolling-btn";
+ const innerSpan = document.createElement('span');
+ innerSpan.textContent = chartInfo.name;
+ nameBtn.appendChild(innerSpan);
+ // if text overflows, add marquee class to animate
+ setTimeout(()=>{
+ try{
+ if (innerSpan.scrollWidth > nameBtn.clientWidth) nameBtn.classList.add('marquee');
+ }catch(e){}
+ }, 120);
nameTd.appendChild(nameBtn);
} else {
nameTd.textContent = `ID: ${chart.chart_id}`;
@@ -267,10 +275,12 @@
// 下载列
const downloadTd = document.createElement("td");
if (chartInfo && chartInfo.file) {
- const downloadLink = document.createElement("a");
+ const downloadLink = document.createElement('a');
downloadLink.href = chartInfo.file;
- downloadLink.download = `chart_${chart.chart_id}.zip`;
- const downloadBtn = document.createElement("button");
+ // use chart name if available, sanitize filename
+ const safeName = (chartInfo.name || `chart_${chart.chart_id}`).replace(/[^\w\-\u4e00-\u9fa5\. ]+/g, '').trim();
+ downloadLink.download = `${safeName || 'chart_' + chart.chart_id}.zip`;
+ const downloadBtn = document.createElement('button');
downloadBtn.className = "dl-btn";
downloadBtn.textContent = "下载";
downloadLink.appendChild(downloadBtn);
@@ -287,22 +297,27 @@
updatePaginationControls();
}
- // 复制谱面ID
- function copyChartId(chartId) {
- navigator.clipboard.writeText(chartId)
- .then(() => {
- // 显示复制成功提示
- const notification = document.getElementById('copy-notification');
- notification.classList.add('show');
- setTimeout(() => {
- notification.classList.remove('show');
- }, 2000);
- })
- .catch(err => {
- console.error('复制失败:', err);
- alert('复制失败,请手动复制谱面ID');
- });
- }
+ // 复制谱面ID(前置#)
+ function copyChartId(chartId) {
+ const idText = '#' + String(chartId);
+ navigator.clipboard.writeText(idText)
+ .then(() => {
+ if (window.showMessage) {
+ window.showMessage('已复制', idText, '');
+ } else {
+ const notification = document.getElementById('copy-notification');
+ if (notification) {
+ notification.textContent = `谱面ID ${idText} 已复制到剪贴板!`;
+ notification.classList.add('show');
+ setTimeout(() => notification.classList.remove('show'), 2000);
+ }
+ }
+ })
+ .catch(err => {
+ console.error('复制失败:', err);
+ alert('复制失败,请手动复制谱面ID');
+ });
+ }
// 更新分页控件状态
function updatePaginationControls() {
diff --git a/js/window-links.js b/js/window-links.js
index d3018e0022eaeb54cd018fb63c8cd96f7b818787..721ca77f547d5567b2c9535279e912100465d601 100644
--- a/js/window-links.js
+++ b/js/window-links.js
@@ -8,6 +8,11 @@
if (/^\s*javascript:/i.test(href)) return false;
// treat privacy page specially
if (/privacy\.html(\b|$)/i.test(href)) return true;
+ // if link points to a downloadable file, do not treat as external page
+ const downloadExt = ['.zip', '.osz', '.osu', '.png', '.jpg', '.jpeg', '.gif', '.mp3', '.ogg', '.7z'];
+ for (let ext of downloadExt) {
+ if (href.toLowerCase().split('?')[0].endsWith(ext)) return false;
+ }
// protocol-relative or absolute
if (/^\/\//.test(href)) return true;
if (/^https?:\/\//i.test(href)) {
@@ -20,43 +25,67 @@
}
}
- function ensureMacWindow() {
- let win = document.getElementById('global-mac-window');
- if (!win) {
- win = document.createElement('mac-window');
- win.id = 'global-mac-window';
- // set a sensible default size
- win.style.setProperty('--width','900px');
- win.style.setProperty('--height','640px');
- document.body.appendChild(win);
+ // create a new mac-window instance and wire stacking/cleanup
+ function createMacWindow() {
+ window._macWindowCounter = (window._macWindowCounter || 0) + 1;
+ window._macWindowZ = window._macWindowZ || 100001;
+ window._macWindowSizeCache = window._macWindowSizeCache || {};
+ const id = 'global-mac-window-' + window._macWindowCounter;
+ const win = document.createElement('mac-window');
+ win.id = id;
+ // bring this window to front by assigning z
+ win.style.setProperty('--mac-window-z', (window._macWindowZ++).toString());
+ // default size
+ win.style.setProperty('--width','900px');
+ win.style.setProperty('--height','640px');
- // cleanup iframe on close
- win.addEventListener('mac-window-close', () => {
- const iframe = win.querySelector('iframe');
- if (iframe) iframe.src = 'about:blank';
- // remove embedded mode and any custom sizing
- win.removeAttribute('embedded');
- win.style.removeProperty('--width');
- win.style.removeProperty('--height');
- // remove slotted title if any
- const title = win.querySelector('h2[data-window-title]');
- if (title) title.remove();
- });
- }
+ // clicking/touching the window brings it to front
+ win.addEventListener('pointerdown', () => {
+ win.style.setProperty('--mac-window-z', (window._macWindowZ++).toString());
+ });
+
+ // cleanup on close: remove element entirely
+ const onClose = () => {
+ const iframe = win.querySelector('iframe');
+ try { if (iframe) iframe.src = 'about:blank'; } catch(e){}
+ // remove loader if any
+ const loader = win.querySelector('.window-loader');
+ try { if (loader && loader.remove) loader.remove(); } catch(e){}
+ // finally remove element
+ try { win.remove(); } catch(e){}
+ };
+ win.addEventListener('mac-window-close', onClose);
+
+ document.body.appendChild(win);
return win;
}
function openUrlInWindow(url, title, width, height) {
- const win = ensureMacWindow();
+ const win = createMacWindow();
// remove existing iframe to avoid duplicates
let iframe = win.querySelector('iframe');
if (iframe) iframe.remove();
// embedded mode: remove title and let iframe fill the window
win.setAttribute('embedded','');
+
+ // if no explicit width/height provided, check cached size for this URL
+ window._macWindowSizeCache = window._macWindowSizeCache || {};
+ const key = url;
+ if (!width && window._macWindowSizeCache[key]) {
+ const s = window._macWindowSizeCache[key];
+ if (s.w) win.style.setProperty('--width', s.w + 'px');
+ if (s.h) win.style.setProperty('--height', s.h + 'px');
+ }
if (width) win.style.setProperty('--width', width);
if (height) win.style.setProperty('--height', height);
+ // add a loading overlay while iframe loads
+ const loader = document.createElement('div');
+ loader.className = 'window-loader';
+ loader.innerHTML = '';
+ win.appendChild(loader);
+
iframe = document.createElement('iframe');
iframe.src = url;
iframe.setAttribute('tabindex','0');
@@ -66,16 +95,48 @@
iframe.style.height = '100%';
iframe.style.border = '0';
iframe.style.minHeight = '320px';
- // make iframe interactive and allow fullscreen/clipboard when possible
iframe.setAttribute('allow','fullscreen; geolocation; microphone; camera; clipboard-read; clipboard-write');
- // some pages may want to be narrower; allow link to specify data-window-width/height
+
+ // remove loader once iframe finishes loading; attempt to cache size when same-origin
+ iframe.addEventListener('load', () => {
+ try { if (loader && loader.remove) loader.remove(); } catch(e){}
+ // try measure content size if same-origin
+ try {
+ const doc = iframe.contentDocument || iframe.contentWindow.document;
+ if (doc) {
+ const fullH = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
+ const fullW = Math.max(doc.documentElement.scrollWidth, doc.body.scrollWidth);
+ // only cache reasonable sizes
+ if (fullH && fullH < 3000) {
+ window._macWindowSizeCache[key] = window._macWindowSizeCache[key] || {};
+ window._macWindowSizeCache[key].h = fullH;
+ window._macWindowSizeCache[key].w = fullW;
+ // apply cached height to window for better fit (desktop)
+ win.style.setProperty('--height', Math.max(320, fullH) + 'px');
+ }
+ }
+ } catch(e) {
+ // cross-origin — ignore
+ }
+ // try inject minimal reset CSS into same-origin iframe to avoid body margin gaps
+ try {
+ const doc = iframe.contentDocument || iframe.contentWindow.document;
+ if (doc) {
+ const style = doc.createElement('style');
+ style.textContent = 'html,body{height:100%;margin:0;padding:0;background:transparent;}body>*{box-sizing:border-box;}';
+ doc.head && doc.head.appendChild(style);
+ }
+ } catch(e) {
+ // cross-origin: cannot inject
+ }
+ setTimeout(() => { try { iframe.contentWindow && iframe.contentWindow.focus(); } catch(e){} }, 120);
+ }, { once: true });
+
win.appendChild(iframe);
+ // let mac-window manage title/navigation if supported
+ try { if (typeof win.registerIframe === 'function') win.registerIframe(iframe, url); } catch(e){}
win.open();
- // focus for accessibility
- setTimeout(() => {
- try { iframe.contentWindow && iframe.contentWindow.focus(); } catch(e){}
- }, 300);
}
// delegate click handler
diff --git a/privacy.html b/privacy.html
index 586d65024bc3ff7dca6183de9df5830525103f97..8713f04d596a5d69928e3d159e4cb65261c5b728 100644
--- a/privacy.html
+++ b/privacy.html
@@ -148,7 +148,7 @@
1. 协议变更
- - 我们有权根据业务发展需要不时修订本协议。从本版本的用户协议发布后开始,用户协议的变更内容将通过网站公告、站内信等显著方式提前通知用户。
+ - 我们有权根据业务发展需要不时修订本协议。用户协议的变更将通过网站公告、站内信等显著方式提前通知用户。
- 若您在新版协议生效后继续使用本服务,即视为接受更新后的协议。
diff --git a/ranks.html b/ranks.html
index 3cd7720dd0148df3a134ec582959af0afa15735b..fec106a18193616c77d3ecf990ce0cba8d3e4d19 100644
--- a/ranks.html
+++ b/ranks.html
@@ -47,18 +47,22 @@
-
-
-
- | 名次 |
- 用户名 |
- 游玩时间 |
-
-
-
- | 加载中... |
-
-
+
-
+
+
+
diff --git a/rooms.html b/rooms.html
index 580181ba1fd07755fbf7f83e6c047c198dbdbb17..2c077fb61b5f67bf18918f736fd429d2e3c7a822 100644
--- a/rooms.html
+++ b/rooms.html
@@ -37,24 +37,29 @@
服务器状态:检测中...
-
-
-
- | 房间名 |
- 房主 |
- 人数 |
- 状态 |
- 循环 |
- 谱面 |
- 曲绘 |
- 下载 |
- 人员 |
-
-
-
- | 加载中... |
-
-
+
@@ -68,6 +73,9 @@
+
+
+
diff --git a/top.html b/top.html
index bea33762dd319db93f1bee7d005b6b854f586400..00a6d1386069e6e0d1146a5c47e6fd2a1f7a8d28 100644
--- a/top.html
+++ b/top.html
@@ -45,21 +45,25 @@
-
-
-
- | 名次 |
- 谱面名称 |
- 谱面ID |
- 游玩人数 |
- 曲绘 |
- 下载 |
-
-
-
- | 加载中... |
-
-
+
@@ -82,7 +86,9 @@
+
+