update [maps, profile, timelines] ✅

#2
Files changed (1) hide show
  1. index.html +372 -3
index.html CHANGED
@@ -6,6 +6,7 @@
6
  <title>HF User Stats — HuggingFace User Statistics</title>
7
  <meta name="description" content="View detailed statistics for any HuggingFace user — models, datasets, spaces, lifetime downloads, and likes.">
8
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
 
9
  <style>
10
  @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap');
11
 
@@ -289,6 +290,105 @@
289
  font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 4px;
290
  }
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  .profile-overview {
293
  display: grid; grid-template-columns: repeat(3, 1fr);
294
  gap: 12px; margin-top: 20px; padding-top: 20px;
@@ -571,6 +671,9 @@
571
  <button id="privateRepoBtn" class="text-btn" title="Access Private Repos">
572
  <i class="fas fa-lock"></i> Token
573
  </button>
 
 
 
574
  <button id="themeToggle" class="icon-btn" aria-label="Toggle Theme">
575
  <i class="fas fa-sun sun-icon"></i>
576
  <i class="fas fa-moon moon-icon"></i>
@@ -628,6 +731,25 @@
628
  </a>
629
  </div>
630
  <div class="profile-fullname" id="profileFullname"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  </div>
632
  </div>
633
  <div class="profile-overview">
@@ -680,6 +802,7 @@
680
  <div class="footer">
681
  Built by <a href="https://hf.co/prithivMLmods" target="_blank" rel="noopener">prithivMLmods</a>
682
  </div>
 
683
  </div>
684
 
685
  <script>
@@ -694,6 +817,8 @@
694
  activeTab: 'models',
695
  activeFilter: 'downloads-alltime',
696
  filterText: '',
 
 
697
  };
698
 
699
  const $ = id => document.getElementById(id);
@@ -730,6 +855,15 @@
730
  overviewModels: $('overviewModels'),
731
  overviewDatasets: $('overviewDatasets'),
732
  overviewSpaces: $('overviewSpaces'),
 
 
 
 
 
 
 
 
 
733
  };
734
 
735
  const FEATURED_USERS = [
@@ -878,6 +1012,49 @@
878
  return null;
879
  }
880
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
  async function fetchUserStats() {
882
  const username = els.usernameInput.value.trim().replace(/^@/, '').replace(/\/$/, '');
883
  if (!username) return showMsg("Please enter a username.", "error");
@@ -898,7 +1075,7 @@
898
  fetchProfile(username),
899
  fetchAllPages(`${HF_API}/models?author=${uEnc}&limit=1000&full=true&${expandParams}`),
900
  fetchAllPages(`${HF_API}/datasets?author=${uEnc}&limit=1000&full=true&${expandParams}`),
901
- fetchAllPages(`${HF_API}/spaces?author=${uEnc}&limit=1000&full=true&expand[]=likes`),
902
  ]);
903
 
904
  state.profile = profileData.status === 'fulfilled' ? profileData.value : null;
@@ -983,6 +1160,9 @@
983
  window.location.hash = username;
984
  document.title = `${username} — HF User Stats`;
985
 
 
 
 
986
  renderProfile();
987
  renderOverview();
988
  setActiveTab('models');
@@ -1015,6 +1195,27 @@
1015
  const fullname = p?.fullname || p?.name || '';
1016
  els.profileFullname.textContent = fullname;
1017
  els.profileFullname.style.display = fullname ? 'block' : 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
  }
1019
 
1020
  function renderOverview() {
@@ -1026,6 +1227,170 @@
1026
  els.tabSpaceCount.textContent = state.spaces.length;
1027
  }
1028
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1029
  function setActiveTab(tab) {
1030
  state.activeTab = tab;
1031
  state.activeFilter = (tab === 'spaces') ? 'likes' : 'downloads-alltime';
@@ -1253,8 +1618,11 @@
1253
  if (tab !== 'spaces') {
1254
  const dlAll = getLifetimeDownloads(item);
1255
  const dlMonth = getMonthlyDownloads(item);
1256
- meta += `<span class="t-stat dl-stat" title="${formatNumFull(dlAll)} downloads (all time)"><i class="fas fa-download"></i> ${formatNum(dlAll)}</span>`;
1257
- meta += `<span class="t-stat monthly-stat" title="${formatNumFull(dlMonth)} downloads (last month)"><i class="fas fa-calendar-alt"></i> ${formatNum(dlMonth)}</span>`;
 
 
 
1258
  }
1259
  const likes = getItemLikes(item);
1260
  meta += `<span class="t-stat like-stat" title="${formatNumFull(likes)} likes"><i class="fas fa-heart"></i> ${formatNum(likes)}</span>`;
@@ -1379,6 +1747,7 @@
1379
  );
1380
 
1381
  els.copyTreeBtn.addEventListener('click', copyFullTree);
 
1382
  parseHash();
1383
  });
1384
  </script>
 
6
  <title>HF User Stats — HuggingFace User Statistics</title>
7
  <meta name="description" content="View detailed statistics for any HuggingFace user — models, datasets, spaces, lifetime downloads, and likes.">
8
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
10
  <style>
11
  @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap');
12
 
 
290
  font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 4px;
291
  }
292
 
293
+ .profile-social {
294
+ display: flex; gap: 16px; margin-top: 8px; flex-wrap: wrap;
295
+ }
296
+ .social-stat {
297
+ display: flex; align-items: center; gap: 6px;
298
+ font-size: 0.82rem; color: var(--text-secondary);
299
+ font-weight: 500; cursor: pointer; transition: color 0.2s;
300
+ }
301
+ .social-stat:hover { color: var(--accent); }
302
+ .social-stat i { font-size: 0.78rem; }
303
+ .social-stat .social-num {
304
+ font-weight: 700; color: var(--text);
305
+ font-family: var(--mono);
306
+ }
307
+
308
+ .contribution-section {
309
+ margin-top: 20px; padding-top: 20px;
310
+ border-top: 1px solid var(--border);
311
+ }
312
+ .contrib-header {
313
+ display: flex; justify-content: space-between;
314
+ align-items: center; margin-bottom: 12px;
315
+ flex-wrap: wrap; gap: 10px;
316
+ }
317
+ .contrib-title {
318
+ font-size: 0.78rem; font-weight: 600;
319
+ color: var(--text-secondary); text-transform: uppercase;
320
+ letter-spacing: 1px;
321
+ }
322
+ .contrib-total {
323
+ font-size: 0.75rem; color: var(--text-secondary);
324
+ font-family: var(--mono); font-weight: 500;
325
+ }
326
+ .contrib-total span { color: var(--accent); font-weight: 700; }
327
+ .contrib-map-wrapper {
328
+ overflow-x: auto; padding-bottom: 4px;
329
+ }
330
+ .contrib-map {
331
+ display: grid;
332
+ grid-template-rows: repeat(7, 14px);
333
+ grid-auto-flow: column;
334
+ grid-auto-columns: 14px;
335
+ gap: 3px;
336
+ min-width: fit-content;
337
+ }
338
+ .contrib-cell {
339
+ width: 14px; height: 14px;
340
+ border-radius: 3px;
341
+ background: var(--border);
342
+ transition: transform 0.15s;
343
+ }
344
+ .contrib-cell:hover { transform: scale(1.4); z-index: 2; position: relative; }
345
+ .contrib-cell[data-level="0"] { background: var(--border); }
346
+ .contrib-cell[data-level="1"] { background: rgba(255, 157, 0, 0.25); }
347
+ .contrib-cell[data-level="2"] { background: rgba(255, 157, 0, 0.5); }
348
+ .contrib-cell[data-level="3"] { background: rgba(255, 157, 0, 0.75); }
349
+ .contrib-cell[data-level="4"] { background: var(--accent); }
350
+ [data-theme="dark"] .contrib-cell[data-level="1"] { background: rgba(251, 191, 36, 0.2); }
351
+ [data-theme="dark"] .contrib-cell[data-level="2"] { background: rgba(251, 191, 36, 0.4); }
352
+ [data-theme="dark"] .contrib-cell[data-level="3"] { background: rgba(251, 191, 36, 0.65); }
353
+ [data-theme="dark"] .contrib-cell[data-level="4"] { background: var(--accent); }
354
+ .contrib-legend {
355
+ display: flex; align-items: center; gap: 6px;
356
+ justify-content: flex-end; margin-top: 8px;
357
+ font-size: 0.68rem; color: var(--text-secondary);
358
+ }
359
+ .contrib-legend .contrib-cell { width: 12px; height: 12px; cursor: default; }
360
+ .contrib-legend .contrib-cell:hover { transform: none; }
361
+ .contrib-months {
362
+ display: flex; gap: 3px; margin-bottom: 6px;
363
+ font-size: 0.65rem; color: var(--text-secondary);
364
+ font-weight: 500; padding-left: 0;
365
+ }
366
+ .contrib-months span {
367
+ min-width: 14px; text-align: center;
368
+ }
369
+ .contrib-tooltip {
370
+ position: fixed; background: var(--header-bg);
371
+ border: 1px solid var(--border); border-radius: 6px;
372
+ padding: 6px 10px; font-size: 0.72rem;
373
+ font-family: var(--mono); pointer-events: none;
374
+ z-index: 100; box-shadow: 0 4px 12px var(--shadow-lg);
375
+ display: none;
376
+ }
377
+
378
+ .contrib-years {
379
+ display: flex; gap: 6px; flex-wrap: wrap;
380
+ }
381
+ .contrib-year-btn {
382
+ background: var(--btn-bg); border: 1px solid var(--btn-border);
383
+ color: var(--text-secondary); padding: 4px 12px;
384
+ border-radius: 14px; cursor: pointer; font-size: 0.72rem;
385
+ font-weight: 600; font-family: var(--mono);
386
+ transition: all 0.2s; white-space: nowrap;
387
+ }
388
+ .contrib-year-btn:hover { border-color: var(--accent); color: var(--text); }
389
+ .contrib-year-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
390
+
391
+
392
  .profile-overview {
393
  display: grid; grid-template-columns: repeat(3, 1fr);
394
  gap: 12px; margin-top: 20px; padding-top: 20px;
 
671
  <button id="privateRepoBtn" class="text-btn" title="Access Private Repos">
672
  <i class="fas fa-lock"></i> Token
673
  </button>
674
+ <button id="screenshotBtn" class="text-btn" title="Screenshot & Share">
675
+ <i class="fas fa-camera"></i> Share
676
+ </button>
677
  <button id="themeToggle" class="icon-btn" aria-label="Toggle Theme">
678
  <i class="fas fa-sun sun-icon"></i>
679
  <i class="fas fa-moon moon-icon"></i>
 
731
  </a>
732
  </div>
733
  <div class="profile-fullname" id="profileFullname"></div>
734
+ <div class="profile-social" id="profileSocial"></div>
735
+ </div>
736
+ </div>
737
+ <div class="contribution-section" id="contribSection" style="display:none;">
738
+ <div class="contrib-header">
739
+ <span class="contrib-title"><i class="fas fa-fire" style="color:var(--accent);margin-right:6px;"></i>Contributions</span>
740
+ <div class="contrib-years" id="contribYears"></div>
741
+ <span class="contrib-total" id="contribTotal"></span>
742
+ </div>
743
+ <div class="contrib-months" id="contribMonths"></div>
744
+ <div class="contrib-map-wrapper"><div class="contrib-map" id="contribMap"></div></div>
745
+ <div class="contrib-legend">
746
+ Less
747
+ <div class="contrib-cell" data-level="0"></div>
748
+ <div class="contrib-cell" data-level="1"></div>
749
+ <div class="contrib-cell" data-level="2"></div>
750
+ <div class="contrib-cell" data-level="3"></div>
751
+ <div class="contrib-cell" data-level="4"></div>
752
+ More
753
  </div>
754
  </div>
755
  <div class="profile-overview">
 
802
  <div class="footer">
803
  Built by <a href="https://hf.co/prithivMLmods" target="_blank" rel="noopener">prithivMLmods</a>
804
  </div>
805
+ <div class="contrib-tooltip" id="contribTooltip"></div>
806
  </div>
807
 
808
  <script>
 
817
  activeTab: 'models',
818
  activeFilter: 'downloads-alltime',
819
  filterText: '',
820
+ contribYear: null, /* null = last 12 months, else specific year */
821
+ followingData: null, /* { users, orgs, total } from profile page scrape */
822
  };
823
 
824
  const $ = id => document.getElementById(id);
 
855
  overviewModels: $('overviewModels'),
856
  overviewDatasets: $('overviewDatasets'),
857
  overviewSpaces: $('overviewSpaces'),
858
+ profileSocial: $('profileSocial'),
859
+ contribSection: $('contribSection'),
860
+ contribMap: $('contribMap'),
861
+ contribMonths: $('contribMonths'),
862
+ contribTotal: $('contribTotal'),
863
+ contribTooltip: $('contribTooltip'),
864
+ contribYears: $('contribYears'),
865
+
866
+ screenshotBtn: $('screenshotBtn'),
867
  };
868
 
869
  const FEATURED_USERS = [
 
1012
  return null;
1013
  }
1014
 
1015
+ async function getHuggingFaceFollowing(username) {
1016
+ const url = `https://huggingface.co/${username}`;
1017
+ try {
1018
+ // Use corsproxy to avoid client-side fetch blocking
1019
+ const response = await fetch(`https://corsproxy.io/?${encodeURIComponent(url)}`);
1020
+ if (!response.ok) {
1021
+ // fallback to direct if proxy fails
1022
+ const fbResponse = await fetch(url);
1023
+ if (!fbResponse.ok) throw new Error(`Failed to fetch profile. Status: ${fbResponse.status}`);
1024
+ const text = await fbResponse.text();
1025
+ return extractStats(text);
1026
+ }
1027
+ const text = await response.text();
1028
+ return extractStats(text);
1029
+ } catch (error) {
1030
+ console.error("Error:", error.message);
1031
+ return null;
1032
+ }
1033
+
1034
+ function extractStats(text) {
1035
+ // Extract the individual metrics from the page's embedded JSON data
1036
+ const usersMatch = text.match(/&quot;numFollowingUsers&quot;:(\d+)/);
1037
+ const orgsMatch = text.match(/&quot;numFollowingOrgs&quot;:(\d+)/);
1038
+
1039
+ if (usersMatch && orgsMatch) {
1040
+ const numUsers = parseInt(usersMatch[1], 10);
1041
+ const numOrgs = parseInt(orgsMatch[1], 10);
1042
+
1043
+ // Hugging Face logic: sum the followed users and followed orgs
1044
+ const totalFollowing = numUsers + numOrgs;
1045
+
1046
+ return {
1047
+ totalFollowing: totalFollowing,
1048
+ followingUsers: numUsers,
1049
+ followingOrgs: numOrgs
1050
+ };
1051
+ } else {
1052
+ console.error("Could not find following stats in the page source.");
1053
+ return null;
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
  async function fetchUserStats() {
1059
  const username = els.usernameInput.value.trim().replace(/^@/, '').replace(/\/$/, '');
1060
  if (!username) return showMsg("Please enter a username.", "error");
 
1075
  fetchProfile(username),
1076
  fetchAllPages(`${HF_API}/models?author=${uEnc}&limit=1000&full=true&${expandParams}`),
1077
  fetchAllPages(`${HF_API}/datasets?author=${uEnc}&limit=1000&full=true&${expandParams}`),
1078
+ fetchAllPages(`${HF_API}/spaces?author=${uEnc}&limit=1000&full=true&expand[]=likes&expand[]=createdAt&expand[]=lastModified`),
1079
  ]);
1080
 
1081
  state.profile = profileData.status === 'fulfilled' ? profileData.value : null;
 
1160
  window.location.hash = username;
1161
  document.title = `${username} — HF User Stats`;
1162
 
1163
+ /* Fetch real following counts (users + orgs) from profile page */
1164
+ state.followingData = await getHuggingFaceFollowing(username);
1165
+
1166
  renderProfile();
1167
  renderOverview();
1168
  setActiveTab('models');
 
1195
  const fullname = p?.fullname || p?.name || '';
1196
  els.profileFullname.textContent = fullname;
1197
  els.profileFullname.style.display = fullname ? 'block' : 'none';
1198
+
1199
+ /* Followers / Following / Orgs */
1200
+ const followers = p?.numFollowers ?? 0;
1201
+ const fd = state.followingData;
1202
+ const followingUsers = fd ? fd.followingUsers : (p?.numFollowing ?? 0);
1203
+ const followingOrgs = fd ? fd.followingOrgs : 0;
1204
+ const followingTotal = fd ? fd.totalFollowing : (p?.numFollowing ?? 0);
1205
+ const memberOrgsCount = Array.isArray(p?.orgs) ? p.orgs.length : 0;
1206
+ let socialHtml = '';
1207
+ socialHtml += `<a class="social-stat" href="https://huggingface.co/${encodeURIComponent(u)}?followers=true" target="_blank" rel="noopener" title="${formatNumFull(followers)} followers">
1208
+ <i class="fas fa-users"></i><span class="social-num">${formatNum(followers)}</span> Followers</a>`;
1209
+ socialHtml += `<a class="social-stat" href="https://huggingface.co/${encodeURIComponent(u)}?following=true" target="_blank" rel="noopener" title="${followingUsers} users + ${followingOrgs} orgs">
1210
+ <i class="fas fa-user-plus"></i><span class="social-num">${formatNum(followingTotal)}</span> Following</a>`;
1211
+ if (memberOrgsCount > 0) {
1212
+ socialHtml += `<span class="social-stat" title="Member of ${memberOrgsCount} organizations">
1213
+ <i class="fas fa-building"></i><span class="social-num">${memberOrgsCount}</span> Orgs</span>`;
1214
+ }
1215
+ els.profileSocial.innerHTML = socialHtml;
1216
+
1217
+ /* Contribution map */
1218
+ renderContribMap();
1219
  }
1220
 
1221
  function renderOverview() {
 
1227
  els.tabSpaceCount.textContent = state.spaces.length;
1228
  }
1229
 
1230
+ function renderContribMap(yearOverride) {
1231
+ const allItems = [...state.models, ...state.datasets, ...state.spaces];
1232
+ if (allItems.length === 0) { els.contribSection.style.display = 'none'; return; }
1233
+
1234
+ /* Determine available years from all items */
1235
+ const allDates = allItems
1236
+ .map(i => i.createdAt || i.lastModified)
1237
+ .filter(Boolean)
1238
+ .map(d => new Date(d));
1239
+ if (allDates.length === 0) { els.contribSection.style.display = 'none'; return; }
1240
+
1241
+ const years = [...new Set(allDates.map(d => d.getFullYear()))].sort((a, b) => b - a);
1242
+ const currentYear = new Date().getFullYear();
1243
+
1244
+ /* Render year filter pills */
1245
+ const selectedYear = yearOverride !== undefined ? yearOverride : state.contribYear;
1246
+ state.contribYear = selectedYear;
1247
+ let yearsHtml = `<button class="contrib-year-btn ${selectedYear === null ? 'active' : ''}" data-year="">Last 12 months</button>`;
1248
+ years.forEach(y => {
1249
+ yearsHtml += `<button class="contrib-year-btn ${selectedYear === y ? 'active' : ''}" data-year="${y}">${y}</button>`;
1250
+ });
1251
+ els.contribYears.innerHTML = yearsHtml;
1252
+ els.contribYears.querySelectorAll('.contrib-year-btn').forEach(btn => {
1253
+ btn.addEventListener('click', () => {
1254
+ const y = btn.dataset.year;
1255
+ renderContribMap(y === '' ? null : parseInt(y));
1256
+ });
1257
+ });
1258
+
1259
+ /* Determine date range */
1260
+ let rangeStart, rangeEnd, rangeLabel;
1261
+ if (selectedYear !== null) {
1262
+ rangeStart = new Date(selectedYear, 0, 1);
1263
+ rangeEnd = new Date(selectedYear, 11, 31, 23, 59, 59);
1264
+ rangeLabel = `in ${selectedYear}`;
1265
+ } else {
1266
+ rangeEnd = new Date();
1267
+ rangeStart = new Date(rangeEnd);
1268
+ rangeStart.setFullYear(rangeStart.getFullYear() - 1);
1269
+ rangeStart.setHours(0,0,0,0);
1270
+ rangeLabel = 'in the last year';
1271
+ }
1272
+
1273
+ /* Count contributions per day */
1274
+ const dayCounts = {};
1275
+ let totalContribs = 0;
1276
+ allItems.forEach(item => {
1277
+ const d = item.createdAt || item.lastModified;
1278
+ if (!d) return;
1279
+ const dt = new Date(d);
1280
+ if (dt < rangeStart || dt > rangeEnd) return;
1281
+ const key = dt.toISOString().slice(0, 10);
1282
+ dayCounts[key] = (dayCounts[key] || 0) + 1;
1283
+ totalContribs++;
1284
+ });
1285
+
1286
+ els.contribSection.style.display = 'block';
1287
+ els.contribTotal.innerHTML = `<span>${totalContribs}</span> contributions ${rangeLabel}`;
1288
+
1289
+ /* Build grid starting from Sunday */
1290
+ const startDate = new Date(rangeStart);
1291
+ startDate.setDate(startDate.getDate() - startDate.getDay());
1292
+ const endDate = new Date(rangeEnd);
1293
+ const totalDays = Math.ceil((endDate - startDate) / 86400000) + 1;
1294
+ const maxCount = Math.max(1, ...Object.values(dayCounts));
1295
+
1296
+ const mapEl = els.contribMap;
1297
+ const tooltip = els.contribTooltip;
1298
+ mapEl.innerHTML = '';
1299
+
1300
+ const fragment = document.createDocumentFragment();
1301
+ const monthLabels = [];
1302
+ let lastMonth = -1;
1303
+
1304
+ for (let i = 0; i < totalDays; i++) {
1305
+ const d = new Date(startDate);
1306
+ d.setDate(d.getDate() + i);
1307
+ const key = d.toISOString().slice(0, 10);
1308
+ const count = dayCounts[key] || 0;
1309
+ let level = 0;
1310
+ if (count > 0) {
1311
+ const ratio = count / maxCount;
1312
+ if (ratio <= 0.25) level = 1;
1313
+ else if (ratio <= 0.5) level = 2;
1314
+ else if (ratio <= 0.75) level = 3;
1315
+ else level = 4;
1316
+ }
1317
+ const cell = document.createElement('div');
1318
+ cell.className = 'contrib-cell';
1319
+ cell.setAttribute('data-level', level);
1320
+ cell.setAttribute('data-date', key);
1321
+ cell.setAttribute('data-count', count);
1322
+ fragment.appendChild(cell);
1323
+
1324
+ if (i % 7 === 0) {
1325
+ const m = d.getMonth();
1326
+ if (m !== lastMonth) {
1327
+ monthLabels.push({ month: m, col: Math.floor(i / 7) });
1328
+ lastMonth = m;
1329
+ }
1330
+ }
1331
+ }
1332
+ mapEl.appendChild(fragment);
1333
+
1334
+ const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1335
+ let monthHtml = '', labelCol = 0;
1336
+ for (const ml of monthLabels) {
1337
+ const gap = ml.col - labelCol;
1338
+ for (let g = 0; g < gap; g++) monthHtml += '<span></span>';
1339
+ monthHtml += `<span>${MONTHS[ml.month]}</span>`;
1340
+ labelCol = ml.col + 1;
1341
+ }
1342
+ els.contribMonths.innerHTML = monthHtml;
1343
+
1344
+ /* Tooltip */
1345
+ mapEl.onmouseover = e => {
1346
+ const cell = e.target.closest('.contrib-cell');
1347
+ if (!cell) { tooltip.style.display = 'none'; return; }
1348
+ const date = cell.dataset.date, count = cell.dataset.count;
1349
+ const dateStr = new Date(date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
1350
+ tooltip.innerHTML = `<strong>${count}</strong> contribution${count !== '1' ? 's' : ''} on ${dateStr}`;
1351
+ tooltip.style.display = 'block';
1352
+ const rect = cell.getBoundingClientRect();
1353
+ tooltip.style.left = (rect.left + rect.width / 2 - tooltip.offsetWidth / 2) + 'px';
1354
+ tooltip.style.top = (rect.top - tooltip.offsetHeight - 8) + 'px';
1355
+ };
1356
+ mapEl.onmouseleave = () => { tooltip.style.display = 'none'; };
1357
+
1358
+ }
1359
+
1360
+ async function takeScreenshot() {
1361
+ const btn = els.screenshotBtn;
1362
+ const orig = btn.innerHTML;
1363
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Capturing…';
1364
+ btn.disabled = true;
1365
+ try {
1366
+ const target = document.querySelector('.app-container');
1367
+ const canvas = await html2canvas(target, {
1368
+ backgroundColor: getComputedStyle(document.body).getPropertyValue('background-color'),
1369
+ scale: 2,
1370
+ useCORS: true,
1371
+ logging: false,
1372
+ });
1373
+ canvas.toBlob(blob => {
1374
+ const url = URL.createObjectURL(blob);
1375
+ const a = document.createElement('a');
1376
+ a.href = url;
1377
+ a.download = `hf-stats-${state.username || 'page'}-${Date.now()}.png`;
1378
+ document.body.appendChild(a);
1379
+ a.click();
1380
+ document.body.removeChild(a);
1381
+ URL.revokeObjectURL(url);
1382
+ btn.innerHTML = '<i class="fas fa-check"></i> Saved!';
1383
+ setTimeout(() => { btn.innerHTML = orig; }, 2000);
1384
+ }, 'image/png');
1385
+ } catch (err) {
1386
+ console.error('Screenshot failed:', err);
1387
+ btn.innerHTML = '<i class="fas fa-times"></i> Failed';
1388
+ setTimeout(() => { btn.innerHTML = orig; }, 2000);
1389
+ } finally {
1390
+ btn.disabled = false;
1391
+ }
1392
+ }
1393
+
1394
  function setActiveTab(tab) {
1395
  state.activeTab = tab;
1396
  state.activeFilter = (tab === 'spaces') ? 'likes' : 'downloads-alltime';
 
1618
  if (tab !== 'spaces') {
1619
  const dlAll = getLifetimeDownloads(item);
1620
  const dlMonth = getMonthlyDownloads(item);
1621
+ if (state.activeFilter === 'downloads-monthly') {
1622
+ meta += `<span class="t-stat monthly-stat" title="${formatNumFull(dlMonth)} downloads (last month)"><i class="fas fa-calendar-alt"></i> ${formatNum(dlMonth)}</span>`;
1623
+ } else {
1624
+ meta += `<span class="t-stat dl-stat" title="${formatNumFull(dlAll)} downloads (all time)"><i class="fas fa-download"></i> ${formatNum(dlAll)}</span>`;
1625
+ }
1626
  }
1627
  const likes = getItemLikes(item);
1628
  meta += `<span class="t-stat like-stat" title="${formatNumFull(likes)} likes"><i class="fas fa-heart"></i> ${formatNum(likes)}</span>`;
 
1747
  );
1748
 
1749
  els.copyTreeBtn.addEventListener('click', copyFullTree);
1750
+ els.screenshotBtn.addEventListener('click', takeScreenshot);
1751
  parseHash();
1752
  });
1753
  </script>