interactive intro to open social

feat: add URL param for shareable filter state, fix mobile layout

- add hide=... URL param to share links with filters applied
- URL params take precedence over localStorage
- wrap filter + watch live buttons in container that stacks vertically on mobile (<768px)
- adjust filter panel and firehose toast positions for mobile

Changed files
+81 -23
src
templates
static
+44 -19
src/templates/app.html
··· 1018 1018 } 1019 1019 1020 1020 .watch-live-btn { 1021 - position: fixed; 1022 - top: clamp(1rem, 2vmin, 1.5rem); 1023 - right: clamp(1rem, 2vmin, 1.5rem); 1024 1021 font-family: inherit; 1025 1022 font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1026 1023 color: var(--text-light); ··· 1028 1025 background: var(--bg); 1029 1026 padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1030 1027 transition: all 0.2s ease; 1031 - z-index: 100; 1032 1028 cursor: pointer; 1033 1029 border-radius: 2px; 1034 1030 display: flex; ··· 1062 1058 animation: pulse 2s ease-in-out infinite; 1063 1059 } 1064 1060 1065 - .filter-btn { 1061 + /* Top right button container for filter and watch live */ 1062 + .top-right-buttons { 1066 1063 position: fixed; 1067 1064 top: clamp(1rem, 2vmin, 1.5rem); 1068 - right: clamp(7rem, 14vmin, 10rem); 1065 + right: clamp(1rem, 2vmin, 1.5rem); 1066 + display: flex; 1067 + flex-direction: row; 1068 + align-items: center; 1069 + gap: clamp(0.5rem, 1vmin, 0.75rem); 1070 + z-index: 100; 1071 + } 1072 + 1073 + @media (max-width: 768px) { 1074 + .top-right-buttons { 1075 + flex-direction: column; 1076 + align-items: flex-end; 1077 + } 1078 + } 1079 + 1080 + .filter-btn { 1069 1081 font-family: inherit; 1070 1082 font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1071 1083 color: var(--text-light); ··· 1073 1085 background: var(--bg); 1074 1086 padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1075 1087 transition: all 0.2s ease; 1076 - z-index: 100; 1077 1088 cursor: pointer; 1078 1089 border-radius: 2px; 1079 1090 display: flex; ··· 1122 1133 max-width: 280px; 1123 1134 display: none; 1124 1135 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 1136 + } 1137 + 1138 + @media (max-width: 768px) { 1139 + .filter-panel { 1140 + top: clamp(6rem, 12vmin, 8rem); 1141 + } 1125 1142 } 1126 1143 1127 1144 @media (prefers-color-scheme: dark) { ··· 1298 1315 pointer-events: none; 1299 1316 max-width: min(300px, calc(100vw - 2rem)); 1300 1317 width: max-content; 1318 + } 1319 + 1320 + @media (max-width: 768px) { 1321 + .firehose-toast { 1322 + top: clamp(7rem, 14vmin, 9rem); 1323 + } 1301 1324 } 1302 1325 1303 1326 .firehose-toast.visible { ··· 2071 2094 <path d="M12 17h.01" /> 2072 2095 </svg> 2073 2096 </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> 2097 + <div class="top-right-buttons"> 2098 + <button class="filter-btn" id="filterBtn"> 2099 + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" 2100 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2101 + <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /> 2102 + </svg> 2103 + <span class="filter-label-text">filter</span> 2104 + <span class="filter-count" id="filterCount" style="display: none;"></span> 2105 + </button> 2106 + <button class="watch-live-btn" id="watchLiveBtn"> 2107 + <span class="watch-indicator"></span> 2108 + <span class="watch-label">watch live</span> 2109 + </button> 2110 + </div> 2082 2111 <div class="filter-panel" id="filterPanel"> 2083 2112 <div class="filter-panel-header"> 2084 2113 <span class="filter-panel-title">show apps</span> ··· 2090 2119 </div> 2091 2120 <div class="filter-list" id="filterList"></div> 2092 2121 </div> 2093 - <button class="watch-live-btn" id="watchLiveBtn"> 2094 - <span class="watch-indicator"></span> 2095 - <span class="watch-label">watch live</span> 2096 - </button> 2097 2122 <div class="pov-indicator">point of view of <a class="pov-handle" id="povHandle" href="#" target="_blank" 2098 2123 rel="noopener noreferrer"></a></div> 2099 2124 <div class="guestbook-sign">sign the guest list</div>
+37 -4
static/app.js
··· 12 12 // APP FILTER FUNCTIONALITY 13 13 // ============================================================================ 14 14 15 - // Load hidden apps from localStorage 15 + // Parse hidden apps from URL param 16 + function getHiddenAppsFromUrl() { 17 + const params = new URLSearchParams(window.location.search); 18 + const hideParam = params.get('hide'); 19 + if (hideParam) { 20 + return new Set(hideParam.split(',').filter(Boolean)); 21 + } 22 + return null; 23 + } 24 + 25 + // Update URL with current hidden apps (without page reload) 26 + function updateUrlWithFilters() { 27 + const params = new URLSearchParams(window.location.search); 28 + if (hiddenApps.size > 0) { 29 + params.set('hide', [...hiddenApps].join(',')); 30 + } else { 31 + params.delete('hide'); 32 + } 33 + const newUrl = params.toString() 34 + ? `${window.location.pathname}?${params.toString()}` 35 + : window.location.pathname; 36 + history.replaceState(null, '', newUrl); 37 + } 38 + 39 + // Load hidden apps from URL param first, then localStorage 16 40 function loadHiddenApps() { 41 + // URL takes precedence over localStorage 42 + const urlHidden = getHiddenAppsFromUrl(); 43 + if (urlHidden) { 44 + hiddenApps = urlHidden; 45 + return; 46 + } 47 + 17 48 try { 18 49 const stored = localStorage.getItem(`atme_hidden_apps_${did}`); 19 50 if (stored) { ··· 24 55 } 25 56 } 26 57 27 - // Save hidden apps to localStorage 58 + // Save hidden apps to localStorage and update URL 28 59 function saveHiddenApps() { 29 60 try { 30 61 localStorage.setItem(`atme_hidden_apps_${did}`, JSON.stringify([...hiddenApps])); 31 62 } catch (e) { 32 63 // Silently fail 33 64 } 65 + updateUrlWithFilters(); 34 66 } 35 67 36 68 // Update filter button state ··· 845 877 846 878 // After all URL validations complete, apply default "valid" filter (hide unresolved) 847 879 Promise.all(validationPromises).then(() => { 848 - // Only apply default if user hasn't set any filters yet 849 - if (hiddenApps.size === 0) { 880 + // Only apply default if user hasn't set any filters yet AND no URL param was provided 881 + const hasUrlFilters = getHiddenAppsFromUrl() !== null; 882 + if (hiddenApps.size === 0 && !hasUrlFilters) { 850 883 // Hide apps with invalid-link class by default 851 884 const appViews = document.querySelectorAll('.app-view'); 852 885 appViews.forEach(view => {