feat(biochem): skeleton loaders for async fetches

Replace plain "Загрузка..." placeholders with shimmer-animated skeletons
matching the actual layout shape:
- library: 12 placeholder cards (canvas + 2 lines)
- reactions: 6 row skeletons (stripe + title + 2 text lines)
- properties: 10 sidebar row shimmers (thumb + 2 lines)
- biochem editor: 4-5 row skeletons for saved-molecules and challenges lists

No existing skeleton classes in ls.css; added local .bc-sk-* helpers per page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-20 19:49:54 +03:00
parent d3b1cd75a0
commit 29ef974e35
4 changed files with 139 additions and 21 deletions
+31 -8
View File
@@ -209,6 +209,26 @@
} }
.detail-open-btn:hover { background: linear-gradient(135deg, rgba(155,93,229,.4), rgba(6,214,224,.25)); border-color: rgba(6,214,224,.5); color: #fff; } .detail-open-btn:hover { background: linear-gradient(135deg, rgba(155,93,229,.4), rgba(6,214,224,.25)); border-color: rgba(6,214,224,.5); color: #fff; }
/* ── Shimmer skeleton ── */
@keyframes bc-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.bc-sk {
background: linear-gradient(90deg,
rgba(255,255,255,0.04) 0%,
rgba(255,255,255,0.10) 50%,
rgba(255,255,255,0.04) 100%);
background-size: 200% 100%;
animation: bc-shimmer 1.6s infinite;
border-radius: 8px;
}
.bc-sk-square { aspect-ratio: 1; }
.bc-sk-line { height: 12px; margin: 6px 0; }
.bc-sk-line.sm { width: 60%; }
.bc-sk-line.md { width: 80%; }
.bc-sk-card { padding: 12px; border: 1px solid rgba(255,255,255,.06); border-radius: 10px; }
/* Empty state */ /* Empty state */
.lib-empty { .lib-empty {
grid-column: 1/-1; grid-column: 1/-1;
@@ -294,14 +314,7 @@
<!-- Content --> <!-- Content -->
<div class="lib-main"> <div class="lib-main">
<div class="lib-scroll" id="lib-scroll"> <div class="lib-scroll" id="lib-scroll">
<div class="lib-grid" id="lib-grid"> <div class="lib-grid" id="lib-grid"></div>
<div class="lib-empty">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3">
<circle cx="12" cy="12" r="3"/><path d="M2 12h3M19 12h3M12 2v3M12 19v3"/>
</svg>
<p>Загрузка молекул…</p>
</div>
</div>
</div> </div>
<!-- Detail panel --> <!-- Detail panel -->
@@ -470,7 +483,17 @@ let selectedId = null;
const CAT_LABELS = { inorganic:'Неорганика', organic:'Органика', biomolecule:'Биомолекулы' }; const CAT_LABELS = { inorganic:'Неорганика', organic:'Органика', biomolecule:'Биомолекулы' };
const DIFF_STARS = ['', '<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', '<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', '<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>']; const DIFF_STARS = ['', '<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', '<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', '<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'];
function bcSkLibrary(n = 12) {
return Array.from({length: n}, () => `
<div class="bc-sk-card">
<div class="bc-sk bc-sk-square" style="margin-bottom:8px"></div>
<div class="bc-sk bc-sk-line md"></div>
<div class="bc-sk bc-sk-line sm"></div>
</div>`).join('');
}
async function init() { async function init() {
document.getElementById('lib-grid').innerHTML = bcSkLibrary(12);
try { try {
[allMols, allReactions] = await Promise.all([ [allMols, allReactions] = await Promise.all([
LS.biochemGetMolecules(), LS.biochemGetMolecules(),
+34 -1
View File
@@ -155,6 +155,27 @@
.add-card:hover { border-color: rgba(155,93,229,.5); color: #9B5DE5; background: rgba(155,93,229,.05); } .add-card:hover { border-color: rgba(155,93,229,.5); color: #9B5DE5; background: rgba(155,93,229,.05); }
.add-card-icon { font-size: 2rem; } .add-card-icon { font-size: 2rem; }
/* ── Shimmer skeleton ── */
@keyframes bc-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.bc-sk {
background: linear-gradient(90deg,
rgba(255,255,255,0.04) 0%,
rgba(255,255,255,0.10) 50%,
rgba(255,255,255,0.04) 100%);
background-size: 200% 100%;
animation: bc-shimmer 1.6s infinite;
border-radius: 8px;
}
.bc-sk-line { height: 10px; margin: 5px 0; }
.bc-sk-line.sm { width: 55%; }
.bc-sk-line.md { width: 80%; }
.bc-sk-molrow { display: flex; align-items: center; gap: 8px; padding: 7px 8px; margin-bottom: 2px; }
.bc-sk-molrow .bc-sk-thumb { width: 34px; height: 34px; border-radius: 8px; flex-shrink: 0; }
.bc-sk-molrow .bc-sk-info { flex: 1; }
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 768px) { @media (max-width: 768px) {
html, body { overflow: auto; } html, body { overflow: auto; }
@@ -193,7 +214,7 @@
<button class="ps-cat" data-cat="amino_acid" onclick="setCat(this,'amino_acid')">АК</button> <button class="ps-cat" data-cat="amino_acid" onclick="setCat(this,'amino_acid')">АК</button>
</div> </div>
</div> </div>
<div class="ps-list" id="mol-list"><div style="padding:20px;color:#555;font-size:.8rem">Загрузка…</div></div> <div class="ps-list" id="mol-list"></div>
</div> </div>
<!-- Right: comparison --> <!-- Right: comparison -->
@@ -294,7 +315,19 @@ let _catFilter = '';
let _searchQ = ''; let _searchQ = '';
let _compare = []; // array of mol objects, max 4 let _compare = []; // array of mol objects, max 4
function bcSkMolList(n = 10) {
return Array.from({length: n}, () => `
<div class="bc-sk-molrow">
<div class="bc-sk bc-sk-thumb"></div>
<div class="bc-sk-info">
<div class="bc-sk bc-sk-line md"></div>
<div class="bc-sk bc-sk-line sm"></div>
</div>
</div>`).join('');
}
async function init() { async function init() {
document.getElementById('mol-list').innerHTML = bcSkMolList(10);
try { try {
_allMols = await LS.biochemGetMolecules(); _allMols = await LS.biochemGetMolecules();
applyFilter(); applyFilter();
+36 -8
View File
@@ -63,6 +63,28 @@
.type-chip.active { background: rgba(155,93,229,.18); border-color: rgba(155,93,229,.6); color: #c084fc; } .type-chip.active { background: rgba(155,93,229,.18); border-color: rgba(155,93,229,.6); color: #c084fc; }
.filter-count { font-size: 0.76rem; color: #444; margin-left: auto; white-space: nowrap; font-weight: 600; } .filter-count { font-size: 0.76rem; color: #444; margin-left: auto; white-space: nowrap; font-weight: 600; }
/* ── Shimmer skeleton ── */
@keyframes bc-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.bc-sk {
background: linear-gradient(90deg,
rgba(255,255,255,0.04) 0%,
rgba(255,255,255,0.10) 50%,
rgba(255,255,255,0.04) 100%);
background-size: 200% 100%;
animation: bc-shimmer 1.6s infinite;
border-radius: 8px;
}
.bc-sk-line { height: 12px; margin: 5px 0; }
.bc-sk-line.sm { width: 60%; }
.bc-sk-line.md { width: 80%; }
.bc-sk-row { display: flex; gap: 12px; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,.05);
border-radius: 12px; margin-bottom: 10px; border: 1px solid rgba(255,255,255,.06); }
.bc-sk-row .bc-sk-avatar { width: 4px; height: auto; border-radius: 4px; flex-shrink: 0; }
.bc-sk-row .bc-sk-text { flex: 1; }
/* ── Scroll area ── */ /* ── Scroll area ── */
.rxn-scroll { flex: 1; overflow-y: auto; padding: 18px 20px 40px; } .rxn-scroll { flex: 1; overflow-y: auto; padding: 18px 20px 40px; }
.rxn-scroll::-webkit-scrollbar { width: 5px; } .rxn-scroll::-webkit-scrollbar { width: 5px; }
@@ -330,14 +352,7 @@
<!-- Content --> <!-- Content -->
<div class="rxn-scroll" id="rxn-scroll"> <div class="rxn-scroll" id="rxn-scroll">
<div id="rxn-list"> <div id="rxn-list"></div>
<div class="rxn-empty">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3">
<path d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18"/>
</svg>
<p>Загрузка реакций…</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -464,7 +479,20 @@ let molCache = {};
let filterType = ''; let filterType = '';
let filterTopic = ''; let filterTopic = '';
function bcSkReactions(n = 6) {
return Array.from({length: n}, () => `
<div class="bc-sk-row">
<div class="bc-sk bc-sk-avatar" style="width:4px;border-radius:4px"></div>
<div class="bc-sk-text">
<div class="bc-sk bc-sk-line md"></div>
<div class="bc-sk bc-sk-line sm"></div>
<div class="bc-sk bc-sk-line" style="width:70%;height:10px;margin-top:8px;opacity:.6"></div>
</div>
</div>`).join('');
}
async function init() { async function init() {
document.getElementById('rxn-list').innerHTML = bcSkReactions(6);
try { try {
allRxns = await LS.biochemGetReactions(); allRxns = await LS.biochemGetReactions();
document.getElementById('subtitle').textContent = `${allRxns.length} реакций в базе`; document.getElementById('subtitle').textContent = `${allRxns.length} реакций в базе`;
+38 -4
View File
@@ -16,6 +16,26 @@
/* ── 3D mode button ── */ /* ── 3D mode button ── */
.tool-btn.mode-3d-active { background: rgba(6,214,224,.2) !important; border-color: #06D6E0 !important; color: #06D6E0 !important; } .tool-btn.mode-3d-active { background: rgba(6,214,224,.2) !important; border-color: #06D6E0 !important; color: #06D6E0 !important; }
/* ── Shimmer skeleton ── */
@keyframes bc-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.bc-sk {
background: linear-gradient(90deg,
rgba(255,255,255,0.04) 0%,
rgba(255,255,255,0.10) 50%,
rgba(255,255,255,0.04) 100%);
background-size: 200% 100%;
animation: bc-shimmer 1.6s infinite;
border-radius: 8px;
}
.bc-sk-line { height: 11px; margin: 5px 0; }
.bc-sk-line.sm { width: 55%; }
.bc-sk-line.md { width: 80%; }
.bc-sk-saved { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,.05); }
.bc-sk-saved .bc-sk-fi { flex: 1; }
/* ── Toolbar ── */ /* ── Toolbar ── */
.bio-toolbar { .bio-toolbar {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
@@ -452,13 +472,13 @@
<button class="chal-type-chip" data-ctype="complete" onclick="setChalFilter(this,'complete')"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Завершить</button> <button class="chal-type-chip" data-ctype="complete" onclick="setChalFilter(this,'complete')"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Завершить</button>
</div> </div>
<div style="padding:0 12px 12px"> <div style="padding:0 12px 12px">
<div id="challenges-list" style="color:#666;font-size:0.82rem">Загрузка…</div> <div id="challenges-list" style="color:#666;font-size:0.82rem"></div>
</div> </div>
</div> </div>
<!-- Saved pane --> <!-- Saved pane -->
<div class="panel-pane" id="pane-saved"> <div class="panel-pane" id="pane-saved">
<div id="saved-list" style="color:#666;font-size:0.82rem">Загрузка…</div> <div id="saved-list" style="color:#666;font-size:0.82rem"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1367,11 +1387,13 @@ function setChalFilter(btn, type) {
} }
async function loadChallenges() { async function loadChallenges() {
const cl = document.getElementById('challenges-list');
cl.innerHTML = bcSkSaved(5);
try { try {
_challenges = await LS.biochemGetChallenges(); _challenges = await LS.biochemGetChallenges();
updateChalProgress(); updateChalProgress();
renderChalList(); renderChalList();
} catch { document.getElementById('challenges-list').innerHTML = '<div style="color:#666">Ошибка загрузки</div>'; } } catch { cl.innerHTML = '<div style="color:#666">Ошибка загрузки</div>'; }
} }
function updateChalProgress() { function updateChalProgress() {
@@ -1586,8 +1608,20 @@ async function submitChoiceAnswer(answer) {
} }
// ── Saved ── // ── Saved ──
function bcSkSaved(n = 4) {
return Array.from({length: n}, () => `
<div class="bc-sk-saved">
<div class="bc-sk-fi">
<div class="bc-sk bc-sk-line md"></div>
<div class="bc-sk bc-sk-line sm"></div>
</div>
<div class="bc-sk" style="width:18px;height:18px;border-radius:4px"></div>
</div>`).join('');
}
async function loadSaved() { async function loadSaved() {
const list = document.getElementById('saved-list'); const list = document.getElementById('saved-list');
list.innerHTML = bcSkSaved(4);
try { try {
const saved = await LS.biochemGetSaved(); const saved = await LS.biochemGetSaved();
if (!saved.length) { list.innerHTML = '<div style="color:#555;font-size:0.8rem">Сохранённых молекул пока нет</div>'; return; } if (!saved.length) { list.innerHTML = '<div style="color:#555;font-size:0.8rem">Сохранённых молекул пока нет</div>'; return; }
@@ -2007,7 +2041,7 @@ window.addEventListener('keydown', e => {
if (hoveredAtomId) { pushHistory(); removeAtom(hoveredAtomId); hoveredAtomId=null; updateInfo(); render(); } if (hoveredAtomId) { pushHistory(); removeAtom(hoveredAtomId); hoveredAtomId=null; updateInfo(); render(); }
else if (hoveredBondId) { pushHistory(); removeBond(hoveredBondId); hoveredBondId=null; updateInfo(); render(); } else if (hoveredBondId) { pushHistory(); removeBond(hoveredBondId); hoveredBondId=null; updateInfo(); render(); }
} }
if (e.key === 'Escape') { cancelChallenge(); bondFromId=null; closeRingMenu(); render(); } if (e.key === 'Escape') { cancelChallenge(); bondFromId=null; closeRingMenu(); if (_is3D) toggle3D(); render(); }
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase()==='z') { e.preventDefault(); undo(); } if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase()==='z') { e.preventDefault(); undo(); }
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase()==='z') || (e.ctrlKey && e.key.toLowerCase()==='y')) { e.preventDefault(); redo(); } if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase()==='z') || (e.ctrlKey && e.key.toLowerCase()==='y')) { e.preventDefault(); redo(); }
// Element shortcuts // Element shortcuts