interactive intro to open social

feat: add app filter to show/hide specific apps

- filter button with panel to toggle app visibility
- "all" shows all apps, "valid" hides unresolvable NSIDs, "none" hides all
- defaults to "valid" mode on first load
- persists filter preferences in localStorage per user
- visible apps reposition evenly around the circle when filtering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+435 -1
src
templates
static
+194
src/templates/app.html
··· 1062 1062 animation: pulse 2s ease-in-out infinite; 1063 1063 } 1064 1064 1065 + .filter-btn { 1066 + position: fixed; 1067 + top: clamp(1rem, 2vmin, 1.5rem); 1068 + right: clamp(7rem, 14vmin, 10rem); 1069 + font-family: inherit; 1070 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1071 + color: var(--text-light); 1072 + border: 1px solid var(--border); 1073 + background: var(--bg); 1074 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1075 + transition: all 0.2s ease; 1076 + z-index: 100; 1077 + cursor: pointer; 1078 + border-radius: 2px; 1079 + display: flex; 1080 + align-items: center; 1081 + gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1082 + } 1083 + 1084 + .filter-btn:hover, 1085 + .filter-btn:active { 1086 + background: var(--surface); 1087 + color: var(--text); 1088 + border-color: var(--text-light); 1089 + } 1090 + 1091 + .filter-btn.active { 1092 + background: var(--surface-hover); 1093 + color: var(--text); 1094 + border-color: var(--text); 1095 + } 1096 + 1097 + .filter-btn.has-filters { 1098 + border-color: var(--text-light); 1099 + } 1100 + 1101 + .filter-count { 1102 + font-size: 0.6rem; 1103 + background: var(--text-light); 1104 + color: var(--bg); 1105 + padding: 0.1rem 0.35rem; 1106 + border-radius: 2px; 1107 + font-weight: 500; 1108 + } 1109 + 1110 + .filter-panel { 1111 + position: fixed; 1112 + top: clamp(3.5rem, 7vmin, 4.5rem); 1113 + right: clamp(1rem, 2vmin, 1.5rem); 1114 + background: var(--surface); 1115 + border: 1px solid var(--border); 1116 + border-radius: 4px; 1117 + padding: 1rem; 1118 + z-index: 250; 1119 + max-height: 60vh; 1120 + overflow-y: auto; 1121 + min-width: 200px; 1122 + max-width: 280px; 1123 + display: none; 1124 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 1125 + } 1126 + 1127 + @media (prefers-color-scheme: dark) { 1128 + .filter-panel { 1129 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 1130 + } 1131 + } 1132 + 1133 + .filter-panel.visible { 1134 + display: block; 1135 + } 1136 + 1137 + .filter-panel-header { 1138 + display: flex; 1139 + justify-content: space-between; 1140 + align-items: center; 1141 + margin-bottom: 0.75rem; 1142 + padding-bottom: 0.5rem; 1143 + border-bottom: 1px solid var(--border); 1144 + } 1145 + 1146 + .filter-panel-title { 1147 + font-size: 0.7rem; 1148 + font-weight: 500; 1149 + color: var(--text); 1150 + text-transform: lowercase; 1151 + } 1152 + 1153 + .filter-panel-actions { 1154 + display: flex; 1155 + gap: 0.5rem; 1156 + } 1157 + 1158 + .filter-action-btn { 1159 + font-family: inherit; 1160 + font-size: 0.6rem; 1161 + color: var(--text-light); 1162 + background: transparent; 1163 + border: none; 1164 + cursor: pointer; 1165 + padding: 0.2rem 0; 1166 + transition: color 0.2s ease; 1167 + } 1168 + 1169 + .filter-action-btn:hover { 1170 + color: var(--text); 1171 + } 1172 + 1173 + .filter-list { 1174 + display: flex; 1175 + flex-direction: column; 1176 + gap: 0.25rem; 1177 + } 1178 + 1179 + .filter-item { 1180 + display: flex; 1181 + align-items: center; 1182 + gap: 0.5rem; 1183 + padding: 0.4rem 0.5rem; 1184 + border-radius: 2px; 1185 + cursor: pointer; 1186 + transition: background 0.15s ease; 1187 + } 1188 + 1189 + .filter-item:hover { 1190 + background: var(--surface-hover); 1191 + } 1192 + 1193 + .filter-checkbox { 1194 + width: 14px; 1195 + height: 14px; 1196 + border: 1px solid var(--border); 1197 + border-radius: 2px; 1198 + background: var(--bg); 1199 + display: flex; 1200 + align-items: center; 1201 + justify-content: center; 1202 + flex-shrink: 0; 1203 + transition: all 0.15s ease; 1204 + } 1205 + 1206 + .filter-item.checked .filter-checkbox { 1207 + background: var(--text); 1208 + border-color: var(--text); 1209 + } 1210 + 1211 + .filter-checkbox-icon { 1212 + width: 10px; 1213 + height: 10px; 1214 + stroke: var(--bg); 1215 + stroke-width: 2; 1216 + opacity: 0; 1217 + transition: opacity 0.15s ease; 1218 + } 1219 + 1220 + .filter-item.checked .filter-checkbox-icon { 1221 + opacity: 1; 1222 + } 1223 + 1224 + .filter-label { 1225 + font-size: 0.7rem; 1226 + color: var(--text-lighter); 1227 + overflow: hidden; 1228 + text-overflow: ellipsis; 1229 + white-space: nowrap; 1230 + } 1231 + 1232 + .filter-item.checked .filter-label { 1233 + color: var(--text); 1234 + } 1235 + 1236 + .app-view.filtered { 1237 + display: none !important; 1238 + } 1239 + 1065 1240 @keyframes pulse { 1066 1241 1067 1242 0%, ··· 1895 2070 <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /> 1896 2071 <path d="M12 17h.01" /> 1897 2072 </svg> 2073 + </div> 2074 + <button class="filter-btn" id="filterBtn"> 2075 + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" 2076 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2077 + <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /> 2078 + </svg> 2079 + <span class="filter-label-text">filter</span> 2080 + <span class="filter-count" id="filterCount" style="display: none;"></span> 2081 + </button> 2082 + <div class="filter-panel" id="filterPanel"> 2083 + <div class="filter-panel-header"> 2084 + <span class="filter-panel-title">show apps</span> 2085 + <div class="filter-panel-actions"> 2086 + <button type="button" class="filter-action-btn" id="filterShowAll">all</button> 2087 + <button type="button" class="filter-action-btn" id="filterHideUnresolved">valid</button> 2088 + <button type="button" class="filter-action-btn" id="filterHideAll">none</button> 2089 + </div> 2090 + </div> 2091 + <div class="filter-list" id="filterList"></div> 1898 2092 </div> 1899 2093 <button class="watch-live-btn" id="watchLiveBtn"> 1900 2094 <span class="watch-indicator"></span>
+241 -1
static/app.js
··· 6 6 let globalHandle = null; 7 7 let globalApps = null; // Store apps for repositioning on resize 8 8 let pageOwnerHasSigned = false; // Track if the page owner (did) has signed the guestbook 9 + let hiddenApps = new Set(); // Track which apps are hidden by filter 10 + 11 + // ============================================================================ 12 + // APP FILTER FUNCTIONALITY 13 + // ============================================================================ 14 + 15 + // Load hidden apps from localStorage 16 + function loadHiddenApps() { 17 + try { 18 + const stored = localStorage.getItem(`atme_hidden_apps_${did}`); 19 + if (stored) { 20 + hiddenApps = new Set(JSON.parse(stored)); 21 + } 22 + } catch (e) { 23 + hiddenApps = new Set(); 24 + } 25 + } 26 + 27 + // Save hidden apps to localStorage 28 + function saveHiddenApps() { 29 + try { 30 + localStorage.setItem(`atme_hidden_apps_${did}`, JSON.stringify([...hiddenApps])); 31 + } catch (e) { 32 + // Silently fail 33 + } 34 + } 35 + 36 + // Update filter button state 37 + function updateFilterButton() { 38 + const filterBtn = document.getElementById('filterBtn'); 39 + const filterCount = document.getElementById('filterCount'); 40 + 41 + if (hiddenApps.size > 0) { 42 + filterBtn.classList.add('has-filters'); 43 + filterCount.textContent = hiddenApps.size; 44 + filterCount.style.display = 'inline-block'; 45 + } else { 46 + filterBtn.classList.remove('has-filters'); 47 + filterCount.style.display = 'none'; 48 + } 49 + } 50 + 51 + // Apply filters to app circles and reposition visible ones 52 + function applyFilters() { 53 + const appViews = document.querySelectorAll('.app-view'); 54 + const visibleApps = []; 55 + 56 + appViews.forEach(view => { 57 + const circle = view.querySelector('.app-circle'); 58 + if (circle) { 59 + const namespace = circle.dataset.namespace; 60 + if (hiddenApps.has(namespace)) { 61 + view.classList.add('filtered'); 62 + } else { 63 + view.classList.remove('filtered'); 64 + visibleApps.push(view); 65 + } 66 + } 67 + }); 68 + 69 + // Reposition visible apps evenly around the circle 70 + if (visibleApps.length > 0 && globalApps) { 71 + const vmin = Math.min(window.innerWidth, window.innerHeight); 72 + const isMobile = window.innerWidth < 768; 73 + const visibleCount = visibleApps.length; 74 + 75 + let circleSize = globalApps._circleSize || 50; 76 + let radius; 77 + 78 + if (isMobile) { 79 + if (visibleCount <= 5) { 80 + radius = vmin * 0.38; 81 + } else if (visibleCount <= 10) { 82 + radius = vmin * 0.4; 83 + } else if (visibleCount <= 20) { 84 + radius = vmin * 0.42; 85 + } else { 86 + radius = vmin * 0.44; 87 + } 88 + radius = Math.max(radius, 120); 89 + } else { 90 + radius = Math.max(vmin * 0.35, 150); 91 + } 92 + 93 + const centerX = window.innerWidth / 2; 94 + const centerY = window.innerHeight / 2; 95 + const circleOffset = circleSize / 2; 96 + 97 + visibleApps.forEach((view, i) => { 98 + const angle = (i / visibleCount) * 2 * Math.PI - Math.PI / 2; 99 + const x = centerX + radius * Math.cos(angle) - circleOffset; 100 + const y = centerY + radius * Math.sin(angle) - circleOffset; 101 + view.style.left = `${x}px`; 102 + view.style.top = `${y}px`; 103 + }); 104 + } 105 + 106 + updateFilterButton(); 107 + saveHiddenApps(); 108 + } 109 + 110 + // Populate filter list with apps 111 + function populateFilterList() { 112 + if (!globalApps) return; 113 + 114 + const filterList = document.getElementById('filterList'); 115 + const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize').sort(); 116 + 117 + filterList.innerHTML = appNames.map(namespace => { 118 + const displayName = namespace.split('.').reverse().join('.'); 119 + const isChecked = !hiddenApps.has(namespace); 120 + return ` 121 + <div class="filter-item ${isChecked ? 'checked' : ''}" data-namespace="${namespace}"> 122 + <div class="filter-checkbox"> 123 + <svg class="filter-checkbox-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> 124 + <polyline points="20 6 9 17 4 12"></polyline> 125 + </svg> 126 + </div> 127 + <span class="filter-label">${displayName}</span> 128 + </div> 129 + `; 130 + }).join(''); 131 + 132 + // Add click handlers 133 + filterList.querySelectorAll('.filter-item').forEach(item => { 134 + item.addEventListener('click', () => { 135 + const namespace = item.dataset.namespace; 136 + if (hiddenApps.has(namespace)) { 137 + hiddenApps.delete(namespace); 138 + item.classList.add('checked'); 139 + } else { 140 + hiddenApps.add(namespace); 141 + item.classList.remove('checked'); 142 + } 143 + applyFilters(); 144 + }); 145 + }); 146 + } 147 + 148 + // Initialize filter panel handlers 149 + function initFilterPanel() { 150 + const filterBtn = document.getElementById('filterBtn'); 151 + const filterPanel = document.getElementById('filterPanel'); 152 + const filterShowAll = document.getElementById('filterShowAll'); 153 + const filterHideUnresolved = document.getElementById('filterHideUnresolved'); 154 + const filterHideAll = document.getElementById('filterHideAll'); 155 + 156 + if (!filterBtn || !filterPanel || !filterShowAll || !filterHideUnresolved || !filterHideAll) { 157 + console.error('Filter panel elements not found:', { filterBtn, filterPanel, filterShowAll, filterHideUnresolved, filterHideAll }); 158 + return; 159 + } 160 + 161 + // Toggle panel 162 + filterBtn.addEventListener('click', (e) => { 163 + e.stopPropagation(); 164 + filterPanel.classList.toggle('visible'); 165 + filterBtn.classList.toggle('active'); 166 + if (filterPanel.classList.contains('visible')) { 167 + populateFilterList(); 168 + } 169 + }); 170 + 171 + // Close panel when clicking outside 172 + document.addEventListener('click', (e) => { 173 + if (!filterPanel.contains(e.target) && e.target !== filterBtn && !filterBtn.contains(e.target)) { 174 + filterPanel.classList.remove('visible'); 175 + filterBtn.classList.remove('active'); 176 + } 177 + }); 178 + 179 + // Show all 180 + filterShowAll.addEventListener('click', (e) => { 181 + e.preventDefault(); 182 + e.stopPropagation(); 183 + hiddenApps.clear(); 184 + populateFilterList(); 185 + applyFilters(); 186 + }); 187 + 188 + // Show only valid (hide unresolved domains) 189 + filterHideUnresolved.addEventListener('click', (e) => { 190 + e.preventDefault(); 191 + e.stopPropagation(); 192 + // Find all apps with invalid-link class and hide them 193 + const appViews = document.querySelectorAll('.app-view'); 194 + hiddenApps.clear(); 195 + appViews.forEach(view => { 196 + const link = view.querySelector('.app-name'); 197 + const circle = view.querySelector('.app-circle'); 198 + if (link && link.classList.contains('invalid-link') && circle) { 199 + hiddenApps.add(circle.dataset.namespace); 200 + } 201 + }); 202 + populateFilterList(); 203 + applyFilters(); 204 + }); 205 + 206 + // Hide all 207 + filterHideAll.addEventListener('click', (e) => { 208 + e.preventDefault(); 209 + e.stopPropagation(); 210 + if (!globalApps) return; 211 + const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize'); 212 + hiddenApps = new Set(appNames); 213 + populateFilterList(); 214 + applyFilters(); 215 + }); 216 + } 217 + 218 + // Load filters on startup 219 + loadHiddenApps(); 9 220 10 221 // Adaptive handle text sizing 11 222 function adaptHandleTextSize(handleEl) { ··· 304 515 }); 305 516 }); 306 517 518 + // Collect validation promises to apply default filter after all complete 519 + const validationPromises = []; 520 + 307 521 appDivs.forEach(({ div, namespace }, i) => { 308 522 // Reverse namespace for display and create URL 309 523 const displayName = namespace.split('.').reverse().join('.'); 310 524 const url = `https://${displayName}`; 311 525 312 526 // Validate URL 313 - fetch(`/api/validate-url?url=${encodeURIComponent(url)}`) 527 + const validationPromise = fetch(`/api/validate-url?url=${encodeURIComponent(url)}`) 314 528 .then(r => r.json()) 315 529 .then(data => { 316 530 const link = div.querySelector('.app-name'); ··· 325 539 .catch(() => { 326 540 // Silently fail validation check 327 541 }); 542 + validationPromises.push(validationPromise); 328 543 329 544 div.addEventListener('click', () => { 330 545 const detail = document.getElementById('detail'); ··· 623 838 resizeTimeout = setTimeout(() => { 624 839 repositionAppCircles(); 625 840 }, 50); // Faster debounce for smoother updates 841 + }); 842 + 843 + // Initialize filter panel 844 + initFilterPanel(); 845 + 846 + // After all URL validations complete, apply default "valid" filter (hide unresolved) 847 + Promise.all(validationPromises).then(() => { 848 + // Only apply default if user hasn't set any filters yet 849 + if (hiddenApps.size === 0) { 850 + // Hide apps with invalid-link class by default 851 + const appViews = document.querySelectorAll('.app-view'); 852 + appViews.forEach(view => { 853 + const link = view.querySelector('.app-name'); 854 + const circle = view.querySelector('.app-circle'); 855 + if (link && link.classList.contains('invalid-link') && circle) { 856 + hiddenApps.add(circle.dataset.namespace); 857 + } 858 + }); 859 + } 860 + applyFilters(); 626 861 }); 627 862 }) 628 863 .catch(e => { ··· 1521 1756 `; 1522 1757 1523 1758 field.appendChild(div); 1759 + 1760 + // Apply filter if this app is hidden 1761 + if (hiddenApps.has(namespace)) { 1762 + div.classList.add('filtered'); 1763 + } 1524 1764 1525 1765 // Fetch avatar 1526 1766 fetchAppAvatar(namespace).then(avatarUrl => {