+194
src/templates/app.html
+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
+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 => {