csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

idk

dawn (May 29, 2026, 4:40 AM +0300) 0b3a02d8 55d6ae4c

+1259 -133
+2
.gitignore
··· 24 24 *.sw? 25 25 /tmp 26 26 /result 27 + .envrc 28 + .direnv
+77 -8
src/components/repo.tsx
··· 1017 1017 return `<span class="untangled-code-diff-op">${escapeHtml(marker)}</span>${highlighted}`; 1018 1018 }; 1019 1019 1020 + /** Parse a URL hash like #L5 or #L3-L8 into {start, end} (1-based, inclusive). */ 1021 + const parseLineHash = (hash: string): { start: number; end: number } | null => { 1022 + const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(hash); 1023 + if (!m) return null; 1024 + const start = parseInt(m[1], 10); 1025 + const end = m[2] ? parseInt(m[2], 10) : start; 1026 + if (isNaN(start) || isNaN(end)) return null; 1027 + return { start: Math.min(start, end), end: Math.max(start, end) }; 1028 + }; 1029 + 1020 1030 export const CodeView: Component<{ 1021 1031 text: string; 1022 1032 id?: string; ··· 1041 1051 ) 1042 1052 : undefined; 1043 1053 1054 + // Line selection state (only used in non-diff mode) 1055 + const [selection, setSelection] = createSignal<{ start: number; end: number } | null>(null); 1056 + // The line number of the last plain click (anchor for shift-click ranges) 1057 + let anchorLine: number | null = null; 1058 + 1059 + const applyHashSelection = (scroll = false) => { 1060 + const parsed = parseLineHash(window.location.hash); 1061 + setSelection(parsed); 1062 + if (scroll && parsed) { 1063 + requestAnimationFrame(() => { 1064 + const target = document.getElementById(`L${parsed.start}`); 1065 + target?.scrollIntoView({ behavior: 'instant', block: 'center' }); 1066 + }); 1067 + } 1068 + }; 1069 + 1070 + const handleLineNumberClick = (lineNum: number, event: MouseEvent) => { 1071 + event.preventDefault(); 1072 + let hash: string; 1073 + if (event.shiftKey && anchorLine !== null) { 1074 + const lo = Math.min(anchorLine, lineNum); 1075 + const hi = Math.max(anchorLine, lineNum); 1076 + hash = `#L${lo}-L${hi}`; 1077 + } else { 1078 + anchorLine = lineNum; 1079 + hash = `#L${lineNum}`; 1080 + } 1081 + history.pushState(null, '', hash); 1082 + setSelection(parseLineHash(hash)); 1083 + }; 1084 + 1085 + onMount(() => { 1086 + if (props.diff) return; 1087 + 1088 + applyHashSelection(true); 1089 + 1090 + const onHashChange = () => applyHashSelection(false); 1091 + window.addEventListener('hashchange', onHashChange); 1092 + window.addEventListener('popstate', onHashChange); 1093 + onCleanup(() => { 1094 + window.removeEventListener('hashchange', onHashChange); 1095 + window.removeEventListener('popstate', onHashChange); 1096 + }); 1097 + }); 1098 + 1099 + const isLineHighlighted = (lineNum: number) => { 1100 + const s = selection(); 1101 + if (!s) return false; 1102 + return lineNum >= s.start && lineNum <= s.end; 1103 + }; 1104 + 1044 1105 return ( 1045 1106 <div class="untangled-code-scroll" style={{ 'max-height': props.maxHeight }}> 1046 1107 <div ··· 1048 1109 class={clsx('untangled-code-view', props.wrap && 'untangled-code-view-wrap')} 1049 1110 > 1050 1111 <For each={lines()}> 1051 - {(line, index) => ( 1052 - <div class={clsx('untangled-code-line', lineClass(line))}> 1053 - <a id={`L${index() + 1}`} href={`#L${index() + 1}`} class="untangled-code-line-number"> 1054 - {index() + 1} 1055 - </a> 1056 - <span class="untangled-code-line-content" innerHTML={highlightedLines()[index()] || ' '} /> 1057 - </div> 1058 - )} 1112 + {(line, index) => { 1113 + const lineNum = index() + 1; 1114 + return ( 1115 + <div class={clsx('untangled-code-line', lineClass(line), !props.diff && isLineHighlighted(lineNum) && 'untangled-code-line--hl')}> 1116 + <a 1117 + id={`L${lineNum}`} 1118 + href={`#L${lineNum}`} 1119 + class="untangled-code-line-number" 1120 + onClick={props.diff ? undefined : (e) => handleLineNumberClick(lineNum, e)} 1121 + > 1122 + {lineNum} 1123 + </a> 1124 + <span class="untangled-code-line-content" innerHTML={highlightedLines()[index()] || ' '} /> 1125 + </div> 1126 + ); 1127 + }} 1059 1128 </For> 1060 1129 </div> 1061 1130 </div>
+644 -41
src/index.css
··· 890 890 min-width: 1px; 891 891 } 892 892 893 - .untangled-home-grid { 893 + .untangled-home-page { 894 + display: flex; 895 + min-width: 0; 896 + flex-direction: column; 897 + gap: 1rem; 898 + } 899 + 900 + .untangled-home-command { 901 + display: grid; 902 + grid-template-columns: minmax(0, 1fr) minmax(22rem, 32rem); 903 + align-items: center; 904 + gap: 1rem; 905 + border-bottom: 1px solid rgb(229 231 235); 906 + padding: 0.5rem 0.25rem 1rem; 907 + } 908 + 909 + .untangled-home-brand { 910 + display: flex; 911 + min-width: 0; 912 + align-items: center; 913 + gap: 0.75rem; 914 + } 915 + 916 + .untangled-home-brand h1 { 917 + margin: 0; 918 + font-size: 1.875rem; 919 + font-weight: 800; 920 + letter-spacing: 0; 921 + line-height: 1.1; 922 + } 923 + 924 + .untangled-home-brand p { 925 + margin: 0.125rem 0 0; 926 + color: rgb(107 114 128); 927 + font-size: 0.875rem; 928 + } 929 + 930 + .untangled-home-jump { 894 931 display: grid; 895 - grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); 896 - gap: 1.25rem; 932 + grid-template-columns: auto minmax(0, 1fr) auto; 933 + align-items: center; 934 + gap: 0.5rem; 935 + } 936 + 937 + .untangled-home-jump .untangled-input { 938 + min-height: 2.25rem; 939 + padding-top: 0.375rem; 940 + padding-bottom: 0.375rem; 941 + } 942 + 943 + .untangled-home-workspace { 944 + display: grid; 945 + grid-template-columns: 14.5rem minmax(0, 1fr) 16rem; 946 + gap: 1rem; 897 947 align-items: start; 898 948 } 899 949 900 - .untangled-home-grid > :first-child { 901 - padding: 1.5rem; 950 + .untangled-home-side { 951 + position: sticky; 952 + top: 0.75rem; 953 + display: flex; 954 + min-width: 0; 955 + flex-direction: column; 956 + gap: 0.75rem; 957 + padding: 0.75rem 0; 902 958 } 903 959 904 - .untangled-home-grid > :first-child > .flex:first-child { 905 - margin-bottom: 1.5rem; 960 + .untangled-home-side-left { 961 + border-right: 1px solid rgb(229 231 235); 962 + padding-right: 1rem; 906 963 } 907 964 908 - .untangled-home-grid form .flex { 909 - align-items: stretch; 965 + .untangled-home-side-right { 966 + border-left: 1px solid rgb(229 231 235); 967 + padding-left: 1rem; 910 968 } 911 969 912 - .untangled-home-grid form button { 913 - min-width: 4.5rem; 970 + .untangled-home-section-header { 971 + display: flex; 972 + align-items: center; 973 + gap: 0.5rem; 974 + padding-bottom: 0.5rem; 975 + border-bottom: 1px solid rgb(229 231 235); 976 + color: inherit; 977 + } 978 + 979 + .untangled-home-section-header h2 { 980 + margin: 0; 981 + font-size: 0.8125rem; 982 + font-weight: 700; 983 + text-transform: uppercase; 984 + letter-spacing: 0.03em; 985 + color: rgb(107 114 128); 986 + } 987 + 988 + .untangled-home-feed { 989 + min-width: 0; 990 + } 991 + 992 + .untangled-home-feed-header { 993 + display: flex; 994 + align-items: center; 995 + justify-content: space-between; 996 + gap: 1rem; 997 + padding-bottom: 0.5rem; 998 + border-bottom: 1px solid rgb(229 231 235); 999 + margin-bottom: 0.5rem; 1000 + } 1001 + 1002 + .untangled-home-feed-title { 1003 + margin: 0; 1004 + display: flex; 1005 + align-items: center; 1006 + gap: 0.5rem; 1007 + font-size: 0.8125rem; 1008 + font-weight: 700; 1009 + text-transform: uppercase; 1010 + letter-spacing: 0.03em; 1011 + color: rgb(107 114 128); 1012 + } 1013 + 1014 + .untangled-home-live-pill { 1015 + display: inline-flex; 1016 + align-items: center; 914 1017 justify-content: center; 1018 + border: 1px solid rgb(209 213 219); 1019 + border-radius: 9999px; 1020 + padding: 0.25rem 0.625rem; 1021 + color: rgb(75 85 99); 1022 + font-size: 0.75rem; 1023 + white-space: nowrap; 915 1024 } 916 1025 917 - .untangled-home-grid form button:hover { 918 - color: rgb(255 255 255); 1026 + .untangled-home-activity-list { 1027 + display: flex; 1028 + min-width: 0; 1029 + flex-direction: column; 1030 + gap: 1rem; 1031 + } 1032 + 1033 + /* upstream-style: individual bordered card per event, with connector lines */ 1034 + .untangled-home-activity-card-wrap { 1035 + position: relative; 1036 + min-width: 0; 1037 + } 1038 + 1039 + .untangled-home-activity-connector { 1040 + position: absolute; 1041 + left: 1rem; 1042 + top: -1rem; 1043 + width: 1px; 1044 + height: 1rem; 1045 + background: rgb(209 213 219); 1046 + } 1047 + 1048 + .untangled-home-activity-card { 1049 + display: flex; 1050 + flex-direction: column; 1051 + border: 1px solid rgb(229 231 235); 1052 + border-radius: 0.125rem; 1053 + overflow: hidden; 1054 + } 1055 + 1056 + .untangled-home-activity-card-header { 1057 + display: flex; 1058 + align-items: center; 1059 + gap: 0.5rem; 1060 + padding: 0.5rem 0.75rem; 1061 + color: rgb(75 85 99); 1062 + font-size: 0.8125rem; 1063 + } 1064 + 1065 + .untangled-home-activity-header-text { 1066 + display: flex; 1067 + min-width: 0; 1068 + flex: 1; 1069 + flex-wrap: wrap; 1070 + align-items: center; 1071 + gap: 0.375rem; 1072 + overflow: hidden; 919 1073 } 920 1074 921 - .untangled-home-grid a { 922 - transition: 923 - background-color 150ms, 924 - border-color 150ms, 925 - color 150ms, 926 - transform 150ms; 1075 + .untangled-home-activity-repo-label { 1076 + min-width: 0; 1077 + overflow: hidden; 1078 + text-overflow: ellipsis; 1079 + white-space: nowrap; 1080 + font-weight: 600; 1081 + color: rgb(17 24 39); 1082 + font-size: 0.8125rem; 1083 + } 1084 + 1085 + .untangled-home-activity-time { 1086 + flex-shrink: 0; 1087 + color: rgb(107 114 128); 1088 + font-size: 0.75rem; 927 1089 } 928 1090 929 - .untangled-home-grid a:hover { 1091 + .untangled-home-activity-card-body { 1092 + display: block; 1093 + padding: 0.5rem 0.75rem; 930 1094 background: rgb(249 250 251); 931 - border-color: rgb(156 163 175); 1095 + border-top: 1px solid rgb(229 231 235); 1096 + color: rgb(55 65 81); 1097 + font-size: 0.8125rem; 1098 + line-height: 1.4; 1099 + text-decoration: none; 1100 + transition: background-color 150ms; 1101 + -webkit-box-orient: vertical; 1102 + -webkit-line-clamp: 2; 1103 + display: -webkit-box; 1104 + overflow: hidden; 1105 + } 1106 + 1107 + .untangled-home-activity-card-body:hover { 1108 + background: rgb(243 244 246); 1109 + text-decoration: none; 1110 + } 1111 + 1112 + .untangled-home-activity-icon { 1113 + display: inline-flex; 1114 + width: 1.5rem; 1115 + height: 1.5rem; 1116 + align-items: center; 1117 + justify-content: center; 1118 + border-radius: 9999px; 1119 + background: rgb(239 246 255); 1120 + color: rgb(37 99 235); 1121 + flex-shrink: 0; 1122 + } 1123 + 1124 + .untangled-home-activity-icon-issue { 1125 + background: rgb(220 252 231); 1126 + color: rgb(22 101 52); 1127 + } 1128 + 1129 + .untangled-home-activity-icon-pull { 1130 + background: rgb(243 232 255); 1131 + color: rgb(126 34 206); 1132 + } 1133 + 1134 + .untangled-home-activity-icon-repo { 1135 + background: rgb(241 245 249); 1136 + color: rgb(51 65 85); 1137 + } 1138 + 1139 + /* old copy/meta styles no longer used but kept for compat with following activity */ 1140 + .untangled-home-activity-copy { 1141 + display: flex; 1142 + min-width: 0; 1143 + flex-direction: column; 1144 + gap: 0.375rem; 1145 + } 1146 + 1147 + .untangled-home-activity-title { 1148 + display: flex; 1149 + min-width: 0; 1150 + flex-wrap: wrap; 1151 + align-items: baseline; 1152 + gap: 0.35rem; 1153 + color: rgb(17 24 39); 1154 + font-size: 0.9375rem; 1155 + } 1156 + 1157 + .untangled-home-activity-title strong { 1158 + min-width: 0; 1159 + overflow-wrap: anywhere; 1160 + font-weight: 700; 1161 + } 1162 + 1163 + .untangled-home-activity-body { 1164 + display: -webkit-box; 1165 + overflow: hidden; 1166 + color: rgb(75 85 99); 1167 + font-size: 0.875rem; 1168 + line-height: 1.4; 1169 + -webkit-box-orient: vertical; 1170 + -webkit-line-clamp: 2; 1171 + } 1172 + 1173 + .untangled-home-activity-meta { 1174 + display: flex; 1175 + align-items: center; 1176 + gap: 0.5rem; 1177 + color: rgb(107 114 128); 1178 + font-size: 0.8125rem; 1179 + } 1180 + 1181 + .untangled-home-feed-empty { 1182 + display: flex; 1183 + min-height: 18rem; 1184 + flex-direction: column; 1185 + align-items: center; 1186 + justify-content: center; 1187 + gap: 0.5rem; 1188 + padding: 2rem; 1189 + text-align: center; 1190 + color: rgb(107 114 128); 1191 + } 1192 + 1193 + .untangled-home-feed-empty h3 { 1194 + margin: 0.5rem 0 0; 1195 + color: rgb(31 41 55); 1196 + font-size: 1rem; 1197 + font-weight: 700; 1198 + } 1199 + 1200 + .untangled-home-feed-empty p { 1201 + max-width: 22rem; 1202 + margin: 0; 1203 + font-size: 0.875rem; 1204 + line-height: 1.5; 1205 + } 1206 + 1207 + .untangled-home-compact-list, 1208 + .untangled-home-following-list { 1209 + display: flex; 1210 + min-width: 0; 1211 + flex-direction: column; 1212 + gap: 0; 1213 + } 1214 + 1215 + /* upstream repoCard style: flat link with icon + name, description below, time at right */ 1216 + .untangled-home-repo-row { 1217 + display: flex; 1218 + min-width: 0; 1219 + flex-direction: column; 1220 + gap: 0.125rem; 1221 + border: 1px solid rgb(229 231 235); 1222 + border-radius: 0.125rem; 1223 + padding: 0.625rem 0.75rem; 1224 + margin-bottom: 0.25rem; 932 1225 color: inherit; 1226 + text-decoration: none; 1227 + transition: background-color 150ms; 1228 + background: rgb(255 255 255); 1229 + } 1230 + 1231 + .untangled-home-repo-row:last-child { 1232 + border-bottom-width: 1px; 1233 + margin-bottom: 0; 933 1234 } 934 1235 935 - .untangled-home-grid > :first-child a { 1236 + .untangled-home-repo-row:hover { 1237 + background: rgb(249 250 251); 1238 + text-decoration: none; 1239 + } 1240 + 1241 + .untangled-home-repo-row-name { 1242 + display: flex; 1243 + align-items: center; 1244 + gap: 0.375rem; 1245 + min-width: 0; 1246 + font-weight: 600; 1247 + font-size: 0.875rem; 1248 + color: rgb(17 24 39); 1249 + overflow: hidden; 1250 + } 1251 + 1252 + .untangled-home-repo-row-name span { 1253 + overflow: hidden; 1254 + text-overflow: ellipsis; 1255 + white-space: nowrap; 1256 + } 1257 + 1258 + .untangled-home-repo-row-desc { 1259 + color: rgb(75 85 99); 1260 + font-size: 0.75rem; 1261 + overflow: hidden; 1262 + text-overflow: ellipsis; 1263 + white-space: nowrap; 936 1264 display: block; 1265 + } 1266 + 1267 + .untangled-home-repo-row-time { 1268 + color: rgb(107 114 128); 1269 + font-size: 0.75rem; 1270 + margin-top: 0.125rem; 1271 + } 1272 + 1273 + .untangled-home-following-row { 1274 + display: flex; 937 1275 min-width: 0; 1276 + align-items: center; 1277 + gap: 0.5rem; 1278 + padding: 0.375rem 0.125rem; 1279 + color: inherit; 1280 + transition: background-color 150ms; 938 1281 } 939 1282 940 - .untangled-home-grid > .flex.flex-col.gap-4 { 941 - min-height: 0; 1283 + .untangled-home-following-row span { 1284 + display: flex; 1285 + min-width: 0; 1286 + flex-direction: column; 1287 + } 1288 + 1289 + .untangled-home-following-row span span { 1290 + overflow: hidden; 1291 + color: rgb(17 24 39); 1292 + font-size: 0.8125rem; 1293 + font-weight: 600; 1294 + text-overflow: ellipsis; 1295 + white-space: nowrap; 1296 + } 1297 + 1298 + .untangled-home-following-row small { 1299 + color: rgb(107 114 128); 1300 + font-size: 0.75rem; 1301 + } 1302 + 1303 + .untangled-home-empty { 1304 + padding: 0.75rem 0.125rem; 1305 + color: rgb(107 114 128); 1306 + font-size: 0.875rem; 1307 + line-height: 1.4; 1308 + } 1309 + 1310 + .untangled-home-below { 1311 + display: flex; 1312 + flex-direction: column; 1313 + gap: 1.5rem; 1314 + padding-top: 0.5rem; 1315 + } 1316 + 1317 + .untangled-home-wide-section { 1318 + display: flex; 1319 + flex-direction: column; 1320 + gap: 0.75rem; 1321 + min-width: 0; 1322 + border-top: 1px solid rgb(209 213 219); 1323 + padding-top: 0.75rem; 1324 + } 1325 + 1326 + .untangled-home-lower-grid { 1327 + display: grid; 1328 + grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr); 1329 + gap: 1.5rem; 1330 + align-items: start; 942 1331 } 943 1332 944 - .untangled-home-grid > .flex.flex-col.gap-4 > .rounded { 1333 + .untangled-home-recent-strip, 1334 + .untangled-home-sample-list { 1335 + display: grid; 1336 + grid-template-columns: repeat(4, minmax(0, 1fr)); 1337 + gap: 0; 1338 + margin-top: 0.75rem; 1339 + border-top: 1px solid rgb(229 231 235); 1340 + border-left: 1px solid rgb(229 231 235); 1341 + } 1342 + 1343 + .untangled-home-recent-link, 1344 + .untangled-home-sample-link { 945 1345 display: flex; 946 - min-height: 0; 1346 + min-width: 0; 947 1347 flex-direction: column; 948 - padding: 1.25rem; 1348 + gap: 0.25rem; 1349 + border-right: 1px solid rgb(229 231 235); 1350 + border-bottom: 1px solid rgb(229 231 235); 1351 + padding: 0.875rem; 1352 + color: inherit; 1353 + text-decoration: none; 1354 + transition: background-color 150ms; 1355 + } 1356 + 1357 + .untangled-home-recent-link:hover, 1358 + .untangled-home-sample-link:hover { 1359 + background: rgb(255 255 255); 1360 + text-decoration: none; 1361 + } 1362 + 1363 + .untangled-home-recent-link span, 1364 + .untangled-home-sample-link strong { 1365 + overflow: hidden; 1366 + color: rgb(17 24 39); 1367 + font-size: 0.875rem; 1368 + font-weight: 700; 1369 + text-overflow: ellipsis; 1370 + white-space: nowrap; 949 1371 } 950 1372 951 - .untangled-home-grid > .flex.flex-col.gap-4 > .rounded > .flex.flex-col.gap-2 { 952 - max-height: 16rem; 953 - overflow-y: auto; 954 - padding-right: 0.125rem; 1373 + .untangled-home-recent-link small, 1374 + .untangled-home-sample-link span { 1375 + overflow: hidden; 1376 + color: rgb(107 114 128); 1377 + font-size: 0.75rem; 1378 + text-overflow: ellipsis; 1379 + white-space: nowrap; 955 1380 } 956 1381 957 - .untangled-home-grid > .flex.flex-col.gap-4 > .rounded a { 958 - display: block; 1382 + .untangled-home-sample-list { 1383 + grid-template-columns: repeat(3, minmax(0, 1fr)); 959 1384 } 960 1385 961 - .untangled-home-grid > .flex.flex-col.gap-4 > .rounded a:hover { 962 - background: rgb(249 250 251); 1386 + .untangled-home-sample-list-compact { 1387 + grid-template-columns: minmax(0, 1fr); 963 1388 } 964 1389 965 1390 @media (prefers-color-scheme: dark) { 966 - .untangled-home-grid a:hover, 967 - .untangled-home-grid > .flex.flex-col.gap-4 > .rounded a:hover { 968 - background: rgb(55 65 81 / 0.4); 969 - border-color: rgb(75 85 99); 1391 + .untangled-home-command, 1392 + .untangled-home-side-left, 1393 + .untangled-home-side-right, 1394 + .untangled-home-section-header, 1395 + .untangled-home-feed-header, 1396 + .untangled-home-wide-section, 1397 + .untangled-home-recent-strip, 1398 + .untangled-home-sample-list, 1399 + .untangled-home-recent-link, 1400 + .untangled-home-sample-link { 1401 + border-color: rgb(55 65 81); 1402 + } 1403 + 1404 + .untangled-home-activity-card, 1405 + .untangled-home-repo-row { 1406 + border-color: rgb(55 65 81); 1407 + } 1408 + 1409 + .untangled-home-activity-connector { 1410 + background: rgb(55 65 81); 1411 + } 1412 + 1413 + .untangled-home-activity-card-header { 1414 + background: rgb(31 41 55); 1415 + color: rgb(156 163 175); 1416 + } 1417 + 1418 + .untangled-home-activity-card-body { 1419 + background: rgb(17 24 39); 1420 + border-color: rgb(55 65 81); 1421 + color: rgb(209 213 219); 1422 + } 1423 + 1424 + .untangled-home-activity-card-body:hover { 1425 + background: rgb(31 41 55); 1426 + } 1427 + 1428 + .untangled-home-activity-repo-label { 970 1429 color: rgb(243 244 246); 971 1430 } 1431 + 1432 + .untangled-home-activity-time { 1433 + color: rgb(107 114 128); 1434 + } 1435 + 1436 + .untangled-home-repo-row { 1437 + background: rgb(31 41 55); 1438 + } 1439 + 1440 + .untangled-home-repo-row:hover { 1441 + background: rgb(55 65 81 / 0.6); 1442 + } 1443 + 1444 + .untangled-home-repo-row-name { 1445 + color: rgb(243 244 246); 1446 + } 1447 + 1448 + .untangled-home-repo-row-desc, 1449 + .untangled-home-repo-row-time, 1450 + .untangled-home-following-row small { 1451 + color: rgb(107 114 128); 1452 + } 1453 + 1454 + .untangled-home-brand p, 1455 + .untangled-home-activity-body, 1456 + .untangled-home-activity-meta, 1457 + .untangled-home-feed-empty, 1458 + .untangled-home-empty, 1459 + .untangled-home-recent-link small, 1460 + .untangled-home-sample-link span { 1461 + color: rgb(156 163 175); 1462 + } 1463 + 1464 + .untangled-home-feed-empty h3, 1465 + .untangled-home-activity-title, 1466 + .untangled-home-following-row span span, 1467 + .untangled-home-recent-link span, 1468 + .untangled-home-sample-link strong { 1469 + color: rgb(243 244 246); 1470 + } 1471 + 1472 + .untangled-home-live-pill { 1473 + border-color: rgb(75 85 99); 1474 + color: rgb(209 213 219); 1475 + } 1476 + 1477 + .untangled-home-recent-link:hover, 1478 + .untangled-home-sample-link:hover { 1479 + background: rgb(55 65 81 / 0.35); 1480 + } 1481 + 1482 + .untangled-home-activity-icon { 1483 + background: rgb(15 23 42); 1484 + color: rgb(191 219 254); 1485 + } 1486 + 1487 + .untangled-home-activity-icon-issue { 1488 + background: rgb(22 101 52 / 0.45); 1489 + color: rgb(134 239 172); 1490 + } 1491 + 1492 + .untangled-home-activity-icon-pull { 1493 + background: rgb(88 28 135 / 0.55); 1494 + color: rgb(216 180 254); 1495 + } 1496 + 1497 + .untangled-home-activity-icon-repo { 1498 + background: rgb(15 23 42); 1499 + color: rgb(191 219 254); 1500 + } 1501 + } 1502 + 1503 + @media (max-width: 1023px) { 1504 + .untangled-home-command { 1505 + grid-template-columns: minmax(0, 1fr); 1506 + } 1507 + 1508 + .untangled-home-workspace { 1509 + display: flex; 1510 + flex-direction: column; 1511 + } 1512 + 1513 + .untangled-home-feed { 1514 + order: -1; 1515 + width: 100%; 1516 + } 1517 + 1518 + .untangled-home-side { 1519 + position: static; 1520 + width: 100%; 1521 + border-right: 0; 1522 + border-left: 0; 1523 + border-top: 1px solid rgb(229 231 235); 1524 + padding: 0.75rem 0 0; 1525 + } 1526 + 1527 + .untangled-home-lower-grid, 1528 + .untangled-home-recent-strip, 1529 + .untangled-home-sample-list { 1530 + grid-template-columns: minmax(0, 1fr); 1531 + } 1532 + } 1533 + 1534 + @media (max-width: 1023px) and (prefers-color-scheme: dark) { 1535 + .untangled-home-side { 1536 + border-top-color: rgb(55 65 81); 1537 + } 1538 + } 1539 + 1540 + @media (max-width: 767px) { 1541 + .untangled-home-brand h1 { 1542 + font-size: 1.5rem; 1543 + } 1544 + 1545 + .untangled-home-jump { 1546 + grid-template-columns: auto minmax(0, 1fr); 1547 + } 1548 + 1549 + .untangled-home-jump button { 1550 + grid-column: 1 / -1; 1551 + } 1552 + 1553 + .untangled-home-feed-header { 1554 + align-items: flex-start; 1555 + flex-direction: column; 1556 + } 1557 + 1558 + .untangled-home-activity-row { 1559 + padding-right: 1rem; 1560 + padding-left: 1rem; 1561 + } 972 1562 } 973 1563 974 1564 .untangled-safe-footer { ··· 1644 2234 1645 2235 .untangled-code-line-number:hover { 1646 2236 text-decoration: underline; 2237 + cursor: pointer; 2238 + } 2239 + 2240 + /* Highlighted line (selected via line number click or URL hash) */ 2241 + .untangled-code-line--hl .untangled-code-line-number, 2242 + .untangled-code-line--hl .untangled-code-line-content { 2243 + background-color: rgb(254 240 138 / 0.4); 2244 + } 2245 + 2246 + @media (prefers-color-scheme: dark) { 2247 + .untangled-code-line--hl .untangled-code-line-number, 2248 + .untangled-code-line--hl .untangled-code-line-content { 2249 + background-color: rgb(161 98 7 / 0.3); 2250 + } 1647 2251 } 1648 2252 1649 2253 .untangled-code-line-content { ··· 2231 2835 2232 2836 @media (max-width: 767px) { 2233 2837 .untangled-repo-overview, 2234 - .untangled-filter-grid, 2235 - .untangled-home-grid { 2838 + .untangled-filter-grid { 2236 2839 grid-template-columns: minmax(0, 1fr); 2237 2840 } 2238 2841
+50
src/lib/api/graph.ts
··· 1 + import { ok } from '@atcute/client'; 2 + import type { ResolvedActor } from '@atcute/identity-resolver'; 3 + import type { Nsid, ResourceUri } from '@atcute/lexicons/syntax'; 4 + import { ShTangledGraphFollow } from '@atcute/tangled'; 5 + 6 + import { getRpc } from './client'; 7 + import { resolveActor } from './identity'; 8 + import { parseAtUri } from './records'; 9 + 10 + const FOLLOW_COLLECTION = 'sh.tangled.graph.follow' as Nsid; 11 + 12 + export interface FollowRecord { 13 + uri: ResourceUri; 14 + cid: string; 15 + rkey: string; 16 + value: ShTangledGraphFollow.Main; 17 + } 18 + 19 + export const listFollowRecords = async (actor: string | ResolvedActor): Promise<FollowRecord[]> => { 20 + const resolved = typeof actor === 'string' ? await resolveActor(actor) : actor; 21 + const rpc = getRpc(resolved.pds); 22 + const records: FollowRecord[] = []; 23 + let cursor: string | undefined; 24 + 25 + do { 26 + const page = await ok( 27 + rpc.get('com.atproto.repo.listRecords', { 28 + params: { 29 + repo: resolved.did, 30 + collection: FOLLOW_COLLECTION, 31 + limit: 100, 32 + cursor, 33 + reverse: true, 34 + }, 35 + }), 36 + ); 37 + 38 + records.push( 39 + ...page.records.map((record) => ({ 40 + uri: record.uri, 41 + cid: record.cid, 42 + rkey: parseAtUri(record.uri).rkey, 43 + value: record.value as ShTangledGraphFollow.Main, 44 + })), 45 + ); 46 + cursor = page.cursor; 47 + } while (cursor); 48 + 49 + return records; 50 + };
+486 -84
src/pages/home.tsx
··· 1 1 import clsx from 'clsx'; 2 - import { A, useNavigate } from '@solidjs/router'; 2 + import { 3 + BookMarked, 4 + CircleDot, 5 + FolderCode, 6 + GalleryVertical, 7 + GitPullRequest, 8 + LoaderCircle, 9 + Star, 10 + Users, 11 + UserRound, 12 + } from 'lucide-solid'; 13 + import { A } from '@solidjs/router'; 3 14 import { createQuery } from '@tanstack/solid-query'; 4 - import { For, Show, createMemo, createSignal, type Component } from 'solid-js'; 15 + import { For, Show, createEffect, createMemo, onCleanup, type Component, type JSX } from 'solid-js'; 5 16 6 - import { LoadingState, buttonStyles, cardStyles, inputStyles } from '../components/common'; 17 + import { Avatar, ErrorState, LoadingState } from '../components/common'; 18 + import { listFollowRecords } from '../lib/api/graph'; 7 19 import { resolveActor } from '../lib/api/identity'; 8 - import { listRepoRecords } from '../lib/api/repos'; 20 + import { listRepoRecords, type RepoRecord } from '../lib/api/repos'; 9 21 import { useAuth } from '../lib/auth'; 10 - import { formatRelativeTime, loadRecentRepos, parseRepoJump } from '../lib/repo-utils'; 22 + import { useLiveEvents, type LiveEvent } from '../lib/live-events'; 23 + import { formatRelativeTime, loadRecentRepos } from '../lib/repo-utils'; 11 24 12 25 const SAMPLE_REPOS = [ 13 26 { owner: 'mary.my.id', slug: 'atcute' }, ··· 15 28 { owner: 'mary.my.id', slug: 'aglais' }, 16 29 ]; 17 30 31 + const HOME_ACTIVITY_SOURCES = [ 32 + 'sh.tangled.repo.issue:repoDid', 33 + 'sh.tangled.repo.issue:repo', 34 + 'sh.tangled.repo.pull:target.repo', 35 + ]; 36 + 37 + type HomeRepoItem = { 38 + owner: string; 39 + slug: string; 40 + title: string; 41 + href: string; 42 + rkey: string; 43 + repoDid?: string; 44 + description?: string; 45 + createdAt?: string; 46 + }; 47 + 48 + type HomeActivityKind = 'issue' | 'pull' | 'repo'; 49 + 50 + type HomeActivityItem = { 51 + id: string; 52 + kind: HomeActivityKind; 53 + title: string; 54 + repoLabel: string; 55 + href: string; 56 + at?: string; 57 + body?: string; 58 + actorDid?: string; 59 + }; 60 + 61 + type FollowingHomeData = { 62 + actors: Array<{ 63 + did: string; 64 + handle: string; 65 + repoCount: number; 66 + }>; 67 + activity: HomeActivityItem[]; 68 + }; 69 + 70 + const repoSlug = (repo: RepoRecord): string => repo.value.name?.trim() || repo.rkey; 71 + 72 + const repoTimestamp = (repo: HomeRepoItem): number => { 73 + const timestamp = repo.createdAt ? new Date(repo.createdAt).getTime() : 0; 74 + return Number.isFinite(timestamp) ? timestamp : 0; 75 + }; 76 + 77 + const activityTimestamp = (item: HomeActivityItem): number => { 78 + const timestamp = item.at ? new Date(item.at).getTime() : 0; 79 + return Number.isFinite(timestamp) ? timestamp : 0; 80 + }; 81 + 82 + const formatCount = (count: number, singular: string, plural = `${singular}s`) => 83 + `${count} ${count === 1 ? singular : plural}`; 84 + 85 + const homeRepoItem = (owner: string, repo: RepoRecord): HomeRepoItem => { 86 + const slug = repoSlug(repo); 87 + return { 88 + owner, 89 + slug, 90 + title: `${owner}/${slug}`, 91 + href: `/${owner}/${slug}`, 92 + rkey: repo.rkey, 93 + repoDid: repo.value.repoDid, 94 + description: repo.value.description, 95 + createdAt: repo.value.createdAt, 96 + }; 97 + }; 98 + 99 + const activityLabelForEvent = (event: LiveEvent): Pick<HomeActivityItem, 'kind' | 'title'> => { 100 + if (event.source.includes('.pull')) { 101 + return { kind: 'pull', title: 'new pull request' }; 102 + } 103 + 104 + return { kind: 'issue', title: 'new issue' }; 105 + }; 106 + 107 + const eventActivityHref = (repo: HomeRepoItem, event: LiveEvent): string => { 108 + const collection = event.source.includes('.pull') ? 'pulls' : 'issues'; 109 + return `${repo.href}/${collection}/${encodeURIComponent(event.sourceRecord)}`; 110 + }; 111 + 112 + const fetchFollowingHomeData = async (viewerDid: string): Promise<FollowingHomeData> => { 113 + const follows = await listFollowRecords(viewerDid); 114 + const resolved = await Promise.all( 115 + follows.slice(0, 8).map(async (follow) => { 116 + try { 117 + const actor = await resolveActor(follow.value.subject); 118 + const repos = await listRepoRecords(actor).catch(() => []); 119 + return { 120 + follow, 121 + actor, 122 + repos, 123 + }; 124 + } catch { 125 + return null; 126 + } 127 + }), 128 + ); 129 + 130 + const actors = resolved 131 + .filter((item): item is NonNullable<typeof item> => Boolean(item)) 132 + .map((item) => ({ 133 + did: item.actor.did, 134 + handle: item.actor.handle, 135 + repoCount: item.repos.length, 136 + })); 137 + 138 + const activity = resolved 139 + .filter((item): item is NonNullable<typeof item> => Boolean(item)) 140 + .flatMap((item) => 141 + item.repos.map((repo) => { 142 + const repoItem = homeRepoItem(item.actor.handle, repo); 143 + return { 144 + id: `following-repo:${repo.uri}`, 145 + kind: 'repo' as const, 146 + title: 'repository published', 147 + repoLabel: repoItem.title, 148 + href: repoItem.href, 149 + at: repoItem.createdAt, 150 + body: repoItem.description, 151 + actorDid: item.actor.did, 152 + }; 153 + }), 154 + ) 155 + .sort((left, right) => activityTimestamp(right) - activityTimestamp(left)) 156 + .slice(0, 8); 157 + 158 + return { actors, activity }; 159 + }; 160 + 18 161 export const HomePage: Component = () => { 19 162 const auth = useAuth(); 20 - const [jump, setJump] = createSignal(''); 163 + const live = useLiveEvents(); 21 164 const recentRepos = createMemo(loadRecentRepos); 22 - const navigate = useNavigate(); 23 165 24 166 const ownReposQuery = createQuery(() => ({ 25 167 queryKey: ['own-repos', auth.currentDid()], 26 168 enabled: Boolean(auth.currentDid()), 27 169 queryFn: async () => { 28 170 const actor = await resolveActor(auth.currentDid()!); 29 - return listRepoRecords(actor); 171 + const records = await listRepoRecords(actor); 172 + return { actor, records }; 30 173 }, 31 174 })); 32 175 33 - const onSubmit = (event: SubmitEvent) => { 34 - event.preventDefault(); 35 - const parsed = parseRepoJump(jump()); 36 - if (!parsed) { 176 + const followingQuery = createQuery(() => ({ 177 + queryKey: ['home-following', auth.currentDid()], 178 + enabled: Boolean(auth.currentDid()), 179 + queryFn: async () => fetchFollowingHomeData(auth.currentDid()!), 180 + })); 181 + 182 + const ownRepoItems = createMemo(() => { 183 + const data = ownReposQuery.data; 184 + if (!data) return []; 185 + return data.records 186 + .map((repo) => homeRepoItem(data.actor.handle, repo)) 187 + .sort((left, right) => repoTimestamp(right) - repoTimestamp(left)); 188 + }); 189 + 190 + const repoByDid = createMemo(() => { 191 + const repos = new Map<string, HomeRepoItem>(); 192 + for (const repo of ownRepoItems()) { 193 + if (repo.repoDid) repos.set(repo.repoDid, repo); 194 + } 195 + return repos; 196 + }); 197 + 198 + createEffect(() => { 199 + if (!auth.currentDid()) { 200 + live.clear(); 201 + return; 202 + } 203 + 204 + if (ownReposQuery.isLoading) { 205 + return; 206 + } 207 + 208 + const subjects = ownRepoItems() 209 + .map((repo) => repo.repoDid) 210 + .filter((repoDid): repoDid is string => Boolean(repoDid)); 211 + 212 + if (subjects.length === 0) { 213 + live.clear(); 37 214 return; 38 215 } 39 216 40 - navigate(`/${parsed.owner}/${parsed.repo}`); 41 - }; 217 + live.subscribe({ 218 + subjects, 219 + sources: HOME_ACTIVITY_SOURCES, 220 + }); 221 + 222 + onCleanup(() => { 223 + live.clear(); 224 + }); 225 + }); 226 + 227 + const activityItems = createMemo<HomeActivityItem[]>(() => { 228 + const repos = repoByDid(); 229 + const liveItems = live.events() 230 + .map((event): HomeActivityItem | null => { 231 + const repo = repos.get(event.subject); 232 + if (!repo) return null; 233 + const label = activityLabelForEvent(event); 234 + return { 235 + id: `live:${event.sourceRecord}:${event.receivedAt}`, 236 + ...label, 237 + repoLabel: repo.title, 238 + href: eventActivityHref(repo, event), 239 + at: event.receivedAt, 240 + }; 241 + }) 242 + .filter((item): item is HomeActivityItem => Boolean(item)); 243 + 244 + const repoItems: HomeActivityItem[] = ownRepoItems().map((repo) => ({ 245 + id: `repo:${repo.owner}/${repo.rkey}`, 246 + kind: 'repo' as const, 247 + title: 'repository published', 248 + repoLabel: repo.title, 249 + href: repo.href, 250 + at: repo.createdAt, 251 + body: repo.description, 252 + })); 253 + 254 + return [...liveItems, ...repoItems] 255 + .sort((left, right) => activityTimestamp(right) - activityTimestamp(left)) 256 + .slice(0, 8); 257 + }); 42 258 43 259 return ( 44 - <div class="untangled-home-grid"> 45 - <div class="flex flex-col gap-4"> 46 - <div class={cardStyles('p-6')}> 47 - <div class="flex items-center gap-3 mb-6"> 48 - <img src="/static/logos/dolly.svg" alt="" class="size-12" /> 49 - <div> 50 - <h1 class="text-2xl font-bold m-0">untangled</h1> 51 - <p class="m-0 text-sm text-gray-500 dark:text-gray-400">tightly-knit social coding</p> 52 - </div> 260 + <div class="untangled-home-page"> 261 + <section class="untangled-home-workspace"> 262 + <aside class="untangled-home-side untangled-home-side-left"> 263 + <HomeSectionHeader icon={<FolderCode class="size-4" />} title="your repositories" /> 264 + <Show 265 + when={auth.currentDid()} 266 + fallback={<HomeEmpty label="Sign in to see your repositories." />} 267 + > 268 + <Show 269 + when={!ownReposQuery.isLoading} 270 + fallback={<LoadingState label="Loading repositories..." />} 271 + > 272 + <Show 273 + when={!ownReposQuery.error} 274 + fallback={<ErrorState message={ownReposQuery.error instanceof Error ? ownReposQuery.error.message : 'Failed to load repositories'} />} 275 + > 276 + <Show 277 + when={ownRepoItems().length > 0} 278 + fallback={<HomeEmpty label="No repositories yet." />} 279 + > 280 + <div class="untangled-home-compact-list"> 281 + <For each={ownRepoItems().slice(0, 6)}> 282 + {(repo) => ( 283 + <RepoLinkRow 284 + href={repo.href} 285 + title={repo.slug} 286 + description={repo.description} 287 + meta={repo.createdAt ? formatRelativeTime(repo.createdAt) : undefined} 288 + /> 289 + )} 290 + </For> 291 + </div> 292 + </Show> 293 + </Show> 294 + </Show> 295 + </Show> 296 + </aside> 297 + 298 + <main class="untangled-home-feed"> 299 + <div class="untangled-home-feed-header"> 300 + <h2 class="untangled-home-feed-title"> 301 + <GalleryVertical class="size-5" /> 302 + activity 303 + </h2> 304 + <Show when={auth.currentDid()}> 305 + <span class="untangled-home-live-pill"> 306 + <Show when={ownRepoItems().length > 0} fallback="idle"> 307 + {formatCount(ownRepoItems().length, 'repo')} watched 308 + </Show> 309 + </span> 310 + </Show> 53 311 </div> 54 312 55 - <form onSubmit={onSubmit} class="flex flex-col gap-3"> 56 - <label class="text-sm font-medium">repository</label> 57 - <div class="flex gap-2"> 58 - <input 59 - value={jump()} 60 - onInput={(event) => setJump(event.currentTarget.value)} 61 - placeholder="mary.my.id/atcute" 62 - class={inputStyles()} 63 - /> 64 - <button type="submit" class={clsx(buttonStyles('primary'), 'shrink-0')}> 65 - open 66 - </button> 67 - </div> 68 - </form> 313 + <Show 314 + when={auth.currentDid()} 315 + fallback={<HomeFeedEmpty title="Sign in for repo activity" body="Issues, pull requests, and repositories from your account will appear here." />} 316 + > 317 + <Show 318 + when={!ownReposQuery.isLoading} 319 + fallback={<HomeFeedLoading />} 320 + > 321 + <Show 322 + when={!ownReposQuery.error} 323 + fallback={<ErrorState message={ownReposQuery.error instanceof Error ? ownReposQuery.error.message : 'Failed to load activity'} />} 324 + > 325 + <Show 326 + when={activityItems().length > 0} 327 + fallback={<HomeFeedEmpty title="No repo activity yet" body="New repository, issue, and pull request activity will land here." />} 328 + > 329 + <div class="untangled-home-activity-list"> 330 + <For each={activityItems()}> 331 + {(item, index) => <ActivityRow item={item} index={index()} />} 332 + </For> 333 + </div> 334 + </Show> 335 + </Show> 336 + </Show> 337 + </Show> 338 + </main> 69 339 70 - <div class="mt-8"> 71 - <div class="text-sm font-medium mb-3">sample repositories</div> 72 - <div class="grid gap-2 sm:grid-cols-2"> 73 - <For each={SAMPLE_REPOS}> 74 - {(repo) => ( 75 - <A href={`/${repo.owner}/${repo.slug}`} class={cardStyles('p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30 no-underline')}> 76 - <div class="font-medium"> 77 - {repo.owner}/{repo.slug} 78 - </div> 79 - </A> 80 - )} 81 - </For> 82 - </div> 83 - </div> 84 - </div> 85 - </div> 340 + <aside class="untangled-home-side untangled-home-side-right"> 341 + <HomeSectionHeader icon={<Users class="size-4" />} title="following" /> 342 + <Show 343 + when={auth.currentDid()} 344 + fallback={<HomeEmpty label="Sign in to see followed developers." />} 345 + > 346 + <Show 347 + when={!followingQuery.isLoading} 348 + fallback={<LoadingState label="Loading following..." />} 349 + > 350 + <Show 351 + when={!followingQuery.error} 352 + fallback={<ErrorState message={followingQuery.error instanceof Error ? followingQuery.error.message : 'Failed to load following'} />} 353 + > 354 + <Show 355 + when={(followingQuery.data?.actors.length ?? 0) > 0} 356 + fallback={<HomeEmpty label="Not following anyone yet." />} 357 + > 358 + <div class="untangled-home-following-list"> 359 + <For each={followingQuery.data?.actors.slice(0, 5) ?? []}> 360 + {(actor) => ( 361 + <div class="untangled-home-following-row"> 362 + <Avatar did={actor.did} size="size-6" /> 363 + <span class="min-w-0"> 364 + <span>{actor.handle}</span> 365 + <small>{formatCount(actor.repoCount, 'repo')}</small> 366 + </span> 367 + </div> 368 + )} 369 + </For> 370 + </div> 371 + </Show> 372 + </Show> 373 + </Show> 374 + </Show> 375 + </aside> 376 + </section> 86 377 87 - <div class="flex flex-col gap-4"> 88 - <div class={cardStyles('p-6')}> 89 - <div class="text-sm font-medium mb-3">recent</div> 378 + <section class="untangled-home-below"> 379 + <div class="untangled-home-wide-section"> 380 + <HomeSectionHeader icon={<Star class="size-4" />} title="recent repositories" /> 90 381 <Show 91 382 when={recentRepos().length > 0} 92 - fallback={<div class="text-sm text-gray-500 dark:text-gray-400">No recent repositories.</div>} 383 + fallback={<HomeEmpty label="No recent repositories." />} 93 384 > 94 - <div class="flex flex-col gap-2"> 385 + <div class="untangled-home-recent-strip"> 95 386 <For each={recentRepos()}> 96 387 {(repo) => ( 97 - <A href={`/${repo.owner}/${repo.slug}`} class="no-underline hover:no-underline rounded border border-gray-200 dark:border-gray-700 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30"> 98 - <div class="font-medium">{repo.title}</div> 99 - <div class="text-xs text-gray-500 dark:text-gray-400">{formatRelativeTime(repo.updatedAt)}</div> 388 + <A href={`/${repo.owner}/${repo.slug}`} class="untangled-home-recent-link"> 389 + <span>{repo.title}</span> 390 + <small>{formatRelativeTime(repo.updatedAt)}</small> 100 391 </A> 101 392 )} 102 393 </For> ··· 104 395 </Show> 105 396 </div> 106 397 107 - <Show when={auth.currentDid()}> 108 - <div class={cardStyles('p-6')}> 109 - <div class="text-sm font-medium mb-3">your repositories</div> 398 + <div class="untangled-home-lower-grid"> 399 + <section class="untangled-home-wide-section"> 400 + <HomeSectionHeader icon={<UserRound class="size-4" />} title="following activity" /> 110 401 <Show 111 - when={!ownReposQuery.isLoading} 112 - fallback={<LoadingState label="Loading repositories..." />} 402 + when={auth.currentDid()} 403 + fallback={<HomeEmpty label="Sign in to see followed repository activity." />} 113 404 > 114 - <div class="flex flex-col gap-2"> 115 - <For each={ownReposQuery.data ?? []}> 116 - {(repo) => ( 117 - <A 118 - href={`/${auth.currentDid()}/${repo.value.name?.trim() || repo.rkey}`} 119 - class="no-underline hover:no-underline rounded border border-gray-200 dark:border-gray-700 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30" 120 - > 121 - <div class="font-medium">{repo.value.name?.trim() || repo.rkey}</div> 122 - <div class="text-xs text-gray-500 dark:text-gray-400"> 123 - {repo.value.description || repo.value.repoDid} 124 - </div> 125 - </A> 126 - )} 127 - </For> 128 - </div> 405 + <Show 406 + when={!followingQuery.isLoading} 407 + fallback={<LoadingState label="Loading followed repositories..." />} 408 + > 409 + <Show 410 + when={!followingQuery.error} 411 + fallback={<ErrorState message={followingQuery.error instanceof Error ? followingQuery.error.message : 'Failed to load following activity'} />} 412 + > 413 + <Show 414 + when={(followingQuery.data?.activity.length ?? 0) > 0} 415 + fallback={<HomeEmpty label="No followed repository activity yet." />} 416 + > 417 + <div class="untangled-home-activity-list"> 418 + <For each={followingQuery.data?.activity ?? []}> 419 + {(item, index) => <ActivityRow item={item} index={index()} />} 420 + </For> 421 + </div> 422 + </Show> 423 + </Show> 424 + </Show> 129 425 </Show> 130 - </div> 131 - </Show> 132 - </div> 426 + </section> 427 + 428 + <section class="untangled-home-wide-section"> 429 + <HomeSectionHeader icon={<FolderCode class="size-4" />} title="sample repositories" /> 430 + <HomeSampleRepos compact /> 431 + </section> 432 + </div> 433 + </section> 133 434 </div> 134 435 ); 135 436 }; 437 + 438 + const HomeSectionHeader: Component<{ icon: JSX.Element; title: string }> = (props) => ( 439 + <header class="untangled-home-section-header"> 440 + {props.icon} 441 + <h2>{props.title}</h2> 442 + </header> 443 + ); 444 + 445 + const HomeEmpty: Component<{ label: string }> = (props) => ( 446 + <div class="untangled-home-empty">{props.label}</div> 447 + ); 448 + 449 + const HomeFeedEmpty: Component<{ title: string; body: string }> = (props) => ( 450 + <div class="untangled-home-feed-empty"> 451 + <FolderCode class="size-8" /> 452 + <h3>{props.title}</h3> 453 + <p>{props.body}</p> 454 + </div> 455 + ); 456 + 457 + const HomeFeedLoading: Component = () => ( 458 + <div class="untangled-home-feed-empty"> 459 + <LoaderCircle class="size-8 animate-spin" /> 460 + <h3>Loading activity</h3> 461 + <p>Checking your repositories.</p> 462 + </div> 463 + ); 464 + 465 + const RepoLinkRow: Component<{ 466 + href: string; 467 + title: string; 468 + description?: string; 469 + meta?: string; 470 + }> = (props) => ( 471 + <A href={props.href} class="untangled-home-repo-row"> 472 + <span class="untangled-home-repo-row-name"> 473 + <BookMarked class="size-3.5 flex-shrink-0" /> 474 + <span>{props.title}</span> 475 + </span> 476 + <Show when={props.description}> 477 + <small class="untangled-home-repo-row-desc">{props.description}</small> 478 + </Show> 479 + <Show when={props.meta}> 480 + <time class="untangled-home-repo-row-time">{props.meta}</time> 481 + </Show> 482 + </A> 483 + ); 484 + 485 + const ActivityRow: Component<{ item: HomeActivityItem; index: number }> = (props) => ( 486 + <div class="untangled-home-activity-card-wrap"> 487 + <Show when={props.index > 0}> 488 + <div class="untangled-home-activity-connector" /> 489 + </Show> 490 + <div class="untangled-home-activity-card"> 491 + <div class="untangled-home-activity-card-header"> 492 + <span class={clsx('untangled-home-activity-icon', `untangled-home-activity-icon-${props.item.kind}`)}> 493 + <ActivityIcon kind={props.item.kind} /> 494 + </span> 495 + <span class="untangled-home-activity-header-text"> 496 + <Show when={props.item.actorDid}> 497 + <Avatar did={props.item.actorDid!} size="size-4" /> 498 + </Show> 499 + <span>{props.item.title}</span> 500 + <strong class="untangled-home-activity-repo-label">{props.item.repoLabel}</strong> 501 + </span> 502 + <time class="untangled-home-activity-time">{formatRelativeTime(props.item.at)}</time> 503 + </div> 504 + <Show when={props.item.body}> 505 + <A href={props.item.href} class="untangled-home-activity-card-body"> 506 + {props.item.body} 507 + </A> 508 + </Show> 509 + </div> 510 + </div> 511 + ); 512 + 513 + const ActivityIcon: Component<{ kind: HomeActivityKind }> = (props) => ( 514 + <Show 515 + when={props.kind === 'pull'} 516 + fallback={ 517 + <Show when={props.kind === 'issue'} fallback={<FolderCode class="size-4" />}> 518 + <CircleDot class="size-4" /> 519 + </Show> 520 + } 521 + > 522 + <GitPullRequest class="size-4" /> 523 + </Show> 524 + ); 525 + 526 + const HomeSampleRepos: Component<{ compact?: boolean }> = (props) => ( 527 + <div class={clsx('untangled-home-sample-list', props.compact && 'untangled-home-sample-list-compact')}> 528 + <For each={SAMPLE_REPOS}> 529 + {(repo) => ( 530 + <A href={`/${repo.owner}/${repo.slug}`} class="untangled-home-sample-link"> 531 + <span>{repo.owner}</span> 532 + <strong>{repo.slug}</strong> 533 + </A> 534 + )} 535 + </For> 536 + </div> 537 + );