csr tangled client in solid-js
15

Configure Feed

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

betetr home pager

dawn (May 29, 2026, 5:27 PM +0300) fa5a57d1 0b3a02d8

+1291 -219
+103 -1
src/components/repo.tsx
··· 19 19 Square, 20 20 SquareCheckBig, 21 21 UnfoldVertical, 22 + BookMarked, 23 + GitFork, 24 + Star, 25 + CircleDot, 26 + GitPullRequest, 22 27 } from 'lucide-solid'; 23 28 import { marked } from 'marked'; 24 29 import { A } from '@solidjs/router'; 25 30 import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 26 31 import type { RepoContext, TreeEntry } from '../lib/api/repos'; 27 - import { formatRelativeTime } from '../lib/repo-utils'; 32 + import { formatRelativeTime, languageColor } from '../lib/repo-utils'; 28 33 import { getTangledAppviewService } from '../lib/settings'; 29 34 import { Avatar, SkeletonBlock, buttonStyles, cardStyles } from './common'; 30 35 ··· 1601 1606 </section> 1602 1607 ); 1603 1608 }; 1609 + 1610 + export interface RepoCardProps { 1611 + owner: string; 1612 + name: string; 1613 + description?: string; 1614 + isFork?: boolean; 1615 + language?: string; 1616 + stars?: number; 1617 + forks?: number; 1618 + openIssues?: number; 1619 + openPRs?: number; 1620 + compact?: boolean; 1621 + href?: string; 1622 + noBorder?: boolean; 1623 + } 1624 + 1625 + export const RepoCard: Component<RepoCardProps> = (props) => { 1626 + const linkHref = () => props.href || `/${props.owner}/${props.name}`; 1627 + const langColor = () => props.language ? languageColor(props.language) : undefined; 1628 + 1629 + return ( 1630 + <div 1631 + class={clsx( 1632 + 'flex flex-col gap-1 bg-white dark:bg-gray-800 drop-shadow-sm', 1633 + props.compact ? 'p-3 md:p-4' : 'p-4 md:p-6', 1634 + !props.noBorder && 'border border-gray-200 dark:border-gray-700 rounded-sm', 1635 + !props.compact && 'min-h-[8rem]', 1636 + props.compact && 'focus-within:bg-gray-100 dark:focus-within:bg-gray-800/80' 1637 + )} 1638 + > 1639 + <div class="font-medium dark:text-white flex items-center justify-between"> 1640 + <div class="flex items-center min-w-0 flex-1 mr-2 text-base"> 1641 + <Show 1642 + when={props.isFork} 1643 + fallback={<BookMarked class="w-4 h-4 mr-1.5 shrink-0 text-gray-400" />} 1644 + > 1645 + <GitFork class="w-4 h-4 mr-1.5 shrink-0 text-gray-400" /> 1646 + </Show> 1647 + <A 1648 + href={linkHref()} 1649 + class={clsx( 1650 + 'truncate min-w-0 no-underline hover:underline text-black dark:text-white font-bold', 1651 + props.compact && 'focus:outline-none' 1652 + )} 1653 + > 1654 + {props.owner}/{props.name} 1655 + </A> 1656 + </div> 1657 + </div> 1658 + 1659 + <Show when={props.description}> 1660 + <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2 mt-1"> 1661 + {props.description} 1662 + </div> 1663 + </Show> 1664 + 1665 + <Show when={props.language || props.stars !== undefined || props.forks !== undefined || props.openIssues !== undefined || props.openPRs !== undefined}> 1666 + <div class="text-gray-400 text-xs font-mono inline-flex gap-4 mt-auto pt-2"> 1667 + <Show when={props.language}> 1668 + <div class="flex gap-1.5 items-center"> 1669 + <span 1670 + class="rounded-full inline-block" 1671 + style={{ 'background-color': langColor(), width: '10px', height: '10px' }} 1672 + /> 1673 + <span>{props.language}</span> 1674 + </div> 1675 + </Show> 1676 + <Show when={props.stars !== undefined && props.stars > 0}> 1677 + <div class="flex gap-1 items-center"> 1678 + <Star class="w-3 h-3 fill-current" /> 1679 + <span>{props.stars}</span> 1680 + </div> 1681 + </Show> 1682 + <Show when={props.forks !== undefined && props.forks > 0}> 1683 + <div class="flex gap-1 items-center"> 1684 + <GitFork class="w-3 h-3" /> 1685 + <span>{props.forks}</span> 1686 + </div> 1687 + </Show> 1688 + <Show when={props.openIssues !== undefined && props.openIssues > 0}> 1689 + <div class="flex gap-1 items-center"> 1690 + <CircleDot class="w-3 h-3" /> 1691 + <span>{props.openIssues}</span> 1692 + </div> 1693 + </Show> 1694 + <Show when={props.openPRs !== undefined && props.openPRs > 0}> 1695 + <div class="flex gap-1 items-center"> 1696 + <GitPullRequest class="w-3 h-3" /> 1697 + <span>{props.openPRs}</span> 1698 + </div> 1699 + </Show> 1700 + </div> 1701 + </Show> 1702 + </div> 1703 + ); 1704 + }; 1705 +
+620 -33
src/index.css
··· 942 942 943 943 .untangled-home-workspace { 944 944 display: grid; 945 - grid-template-columns: 14.5rem minmax(0, 1fr) 16rem; 946 - gap: 1rem; 945 + grid-template-columns: minmax(0, 1fr) 22rem; 946 + gap: 1.5rem; 947 947 align-items: start; 948 948 } 949 949 950 950 .untangled-home-side { 951 - position: sticky; 952 - top: 0.75rem; 953 951 display: flex; 954 952 min-width: 0; 955 953 flex-direction: column; 956 954 gap: 0.75rem; 957 - padding: 0.75rem 0; 955 + padding: 0 0 0.75rem; 956 + position: sticky; 957 + top: 0; 958 958 } 959 959 960 960 .untangled-home-side-left { ··· 963 963 } 964 964 965 965 .untangled-home-side-right { 966 - border-left: 1px solid rgb(229 231 235); 967 - padding-left: 1rem; 966 + padding-left: 1.5rem; 968 967 } 969 968 970 969 .untangled-home-section-header { 970 + position: sticky; 971 + top: 0; 972 + z-index: 10; 973 + background: rgb(241 245 249); 974 + padding-top: 0.5rem; 971 975 display: flex; 972 976 align-items: center; 973 - gap: 0.5rem; 974 - padding-bottom: 0.5rem; 975 - border-bottom: 1px solid rgb(229 231 235); 977 + gap: 0.75rem; 978 + padding-bottom: 1rem; 976 979 color: inherit; 977 980 } 978 981 979 982 .untangled-home-section-header h2 { 980 983 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); 984 + font-size: 1.25rem; 985 + font-weight: 750; 986 + color: rgb(17 24 39); 986 987 } 987 988 988 989 .untangled-home-feed { ··· 990 991 } 991 992 992 993 .untangled-home-feed-header { 994 + position: sticky; 995 + top: 0; 996 + z-index: 10; 997 + background: rgb(241 245 249); 998 + padding-top: 0.5rem; 993 999 display: flex; 994 1000 align-items: center; 995 1001 justify-content: space-between; 996 1002 gap: 1rem; 997 - padding-bottom: 0.5rem; 998 - border-bottom: 1px solid rgb(229 231 235); 1003 + padding-bottom: 1rem; 999 1004 margin-bottom: 0.5rem; 1000 1005 } 1001 1006 ··· 1003 1008 margin: 0; 1004 1009 display: flex; 1005 1010 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); 1011 + gap: 0.75rem; 1012 + font-size: 1.25rem; 1013 + font-weight: 750; 1014 + color: rgb(17 24 39); 1012 1015 } 1013 1016 1014 1017 .untangled-home-live-pill { ··· 1204 1207 line-height: 1.5; 1205 1208 } 1206 1209 1207 - .untangled-home-compact-list, 1210 + .untangled-home-compact-list { 1211 + display: flex; 1212 + min-width: 0; 1213 + flex-direction: column; 1214 + gap: 0.5rem; 1215 + } 1216 + 1208 1217 .untangled-home-following-list { 1209 1218 display: flex; 1210 1219 min-width: 0; 1211 1220 flex-direction: column; 1212 - gap: 0; 1221 + gap: 0.75rem; 1213 1222 } 1214 1223 1215 1224 /* upstream repoCard style: flat link with icon + name, description below, time at right */ ··· 1221 1230 border: 1px solid rgb(229 231 235); 1222 1231 border-radius: 0.125rem; 1223 1232 padding: 0.625rem 0.75rem; 1224 - margin-bottom: 0.25rem; 1225 1233 color: inherit; 1226 1234 text-decoration: none; 1227 1235 transition: background-color 150ms; ··· 1230 1238 1231 1239 .untangled-home-repo-row:last-child { 1232 1240 border-bottom-width: 1px; 1233 - margin-bottom: 0; 1234 1241 } 1235 1242 1236 1243 .untangled-home-repo-row:hover { ··· 1264 1271 display: block; 1265 1272 } 1266 1273 1274 + .untangled-home-repo-row-footer { 1275 + display: flex; 1276 + align-items: center; 1277 + gap: 0.75rem; 1278 + margin-top: 0.125rem; 1279 + } 1280 + 1281 + .untangled-home-repo-row-lang { 1282 + display: inline-flex; 1283 + align-items: center; 1284 + gap: 0.25rem; 1285 + font-size: 0.75rem; 1286 + color: rgb(75 85 99); 1287 + } 1288 + 1289 + .untangled-home-repo-row-lang-dot { 1290 + display: inline-block; 1291 + width: 0.5rem; 1292 + height: 0.5rem; 1293 + border-radius: 9999px; 1294 + } 1295 + 1267 1296 .untangled-home-repo-row-time { 1268 1297 color: rgb(107 114 128); 1269 1298 font-size: 0.75rem; 1270 - margin-top: 0.125rem; 1271 1299 } 1272 1300 1273 1301 .untangled-home-following-row { ··· 1311 1339 display: flex; 1312 1340 flex-direction: column; 1313 1341 gap: 1.5rem; 1314 - padding-top: 0.5rem; 1342 + padding-top: 2rem; 1343 + padding-bottom: 4rem; 1344 + width: 100vw; 1345 + margin-left: calc(50% - 50vw); 1346 + margin-right: calc(50% - 50vw); 1347 + padding-left: 2rem; 1348 + padding-right: 2rem; 1349 + background: rgb(248 250 252); 1350 + box-sizing: border-box; 1351 + } 1352 + 1353 + @media (prefers-color-scheme: dark) { 1354 + .untangled-home-below { 1355 + background: rgb(15 23 42); 1356 + } 1357 + } 1358 + 1359 + @media (min-width: 1280px) { 1360 + .untangled-home-below { 1361 + padding-left: 4rem; 1362 + padding-right: 4rem; 1363 + } 1315 1364 } 1316 1365 1317 1366 .untangled-home-wide-section { ··· 1319 1368 flex-direction: column; 1320 1369 gap: 0.75rem; 1321 1370 min-width: 0; 1322 - border-top: 1px solid rgb(209 213 219); 1323 - padding-top: 0.75rem; 1324 1371 } 1325 1372 1326 1373 .untangled-home-lower-grid { 1327 1374 display: grid; 1328 - grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr); 1375 + grid-template-columns: 1fr; 1329 1376 gap: 1.5rem; 1330 1377 align-items: start; 1331 1378 } 1332 1379 1380 + .untangled-home-promos-column { 1381 + order: 3; 1382 + } 1383 + 1384 + .untangled-home-lower-left { 1385 + order: 1; 1386 + } 1387 + 1388 + .untangled-home-lower-right { 1389 + order: 2; 1390 + } 1391 + 1392 + @media (min-width: 768px) { 1393 + .untangled-home-lower-grid { 1394 + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); 1395 + } 1396 + 1397 + .untangled-home-promos-column { 1398 + grid-column: span 2; 1399 + display: flex; 1400 + flex-direction: column; 1401 + gap: 1rem; 1402 + } 1403 + } 1404 + 1405 + @media (min-width: 1024px) { 1406 + .untangled-home-lower-grid { 1407 + grid-template-columns: 18rem minmax(0, 1fr) 20rem; 1408 + gap: 2rem; 1409 + } 1410 + 1411 + .untangled-home-promos-column { 1412 + grid-column: span 1; 1413 + display: flex; 1414 + flex-direction: column; 1415 + gap: 1.25rem; 1416 + order: 0; 1417 + } 1418 + 1419 + .untangled-home-lower-left, 1420 + .untangled-home-lower-right { 1421 + order: 0; 1422 + } 1423 + } 1424 + 1425 + @media (min-width: 1280px) { 1426 + .untangled-home-lower-grid { 1427 + grid-template-columns: 22rem minmax(0, 1fr) 24rem; 1428 + gap: 3.5rem; 1429 + } 1430 + } 1431 + 1432 + .untangled-home-announcements-grid { 1433 + display: grid; 1434 + grid-template-columns: 1fr; 1435 + gap: 1rem; 1436 + } 1437 + 1438 + @media (min-width: 768px) and (max-width: 1023px) { 1439 + .untangled-home-announcements-grid { 1440 + grid-template-columns: repeat(2, 1fr); 1441 + } 1442 + } 1443 + 1444 + /* Vouch Card styles */ 1445 + .untangled-home-vouch-card { 1446 + display: flex; 1447 + flex-direction: column; 1448 + border: 1px solid rgb(229 231 235); 1449 + border-radius: 0.25rem; 1450 + background: rgb(255 255 255); 1451 + padding: 1.25rem; 1452 + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 1453 + gap: 1rem; 1454 + } 1455 + 1456 + @media (prefers-color-scheme: dark) { 1457 + .untangled-home-vouch-card { 1458 + border-color: rgb(55 65 81); 1459 + background: rgb(31 41 55); 1460 + } 1461 + } 1462 + 1463 + .untangled-home-vouch-header { 1464 + display: flex; 1465 + align-items: center; 1466 + justify-content: space-between; 1467 + } 1468 + 1469 + .untangled-home-vouch-title { 1470 + margin: 0; 1471 + display: flex; 1472 + align-items: center; 1473 + gap: 0.5rem; 1474 + font-size: 1rem; 1475 + font-weight: 700; 1476 + color: rgb(17 24 39); 1477 + } 1478 + 1479 + @media (prefers-color-scheme: dark) { 1480 + .untangled-home-vouch-title { 1481 + color: rgb(243 244 246); 1482 + } 1483 + } 1484 + 1485 + .untangled-home-vouch-list { 1486 + display: flex; 1487 + flex-direction: column; 1488 + gap: 1rem; 1489 + } 1490 + 1491 + .untangled-home-vouch-item { 1492 + display: flex; 1493 + align-items: center; 1494 + gap: 0.75rem; 1495 + } 1496 + 1497 + .untangled-home-vouch-info { 1498 + display: flex; 1499 + flex-direction: column; 1500 + min-width: 0; 1501 + flex: 1; 1502 + } 1503 + 1504 + .untangled-home-vouch-name { 1505 + font-size: 0.875rem; 1506 + font-weight: 600; 1507 + color: rgb(17 24 39); 1508 + text-decoration: none; 1509 + } 1510 + 1511 + .untangled-home-vouch-name:hover { 1512 + text-decoration: underline; 1513 + } 1514 + 1515 + @media (prefers-color-scheme: dark) { 1516 + .untangled-home-vouch-name { 1517 + color: rgb(243 244 246); 1518 + } 1519 + } 1520 + 1521 + .untangled-home-vouch-reason { 1522 + font-size: 0.75rem; 1523 + color: rgb(107 114 128); 1524 + overflow: hidden; 1525 + text-overflow: ellipsis; 1526 + white-space: nowrap; 1527 + } 1528 + 1529 + @media (prefers-color-scheme: dark) { 1530 + .untangled-home-vouch-reason { 1531 + color: rgb(156 163 175); 1532 + } 1533 + } 1534 + 1535 + .untangled-home-vouch-action-btn { 1536 + display: inline-flex; 1537 + align-items: center; 1538 + justify-content: center; 1539 + gap: 0.375rem; 1540 + font-size: 0.75rem; 1541 + font-weight: 600; 1542 + padding: 0.375rem 0.75rem; 1543 + border-radius: 0.125rem; 1544 + border: 1px solid rgb(209 213 219); 1545 + background: rgb(255 255 255); 1546 + color: rgb(55 65 81); 1547 + cursor: pointer; 1548 + transition: all 150ms; 1549 + } 1550 + 1551 + .untangled-home-vouch-action-btn:hover { 1552 + background: rgb(249 250 251); 1553 + border-color: rgb(156 163 175); 1554 + } 1555 + 1556 + .untangled-home-vouch-action-btn.is-vouched { 1557 + background: rgb(220 252 231); 1558 + border-color: rgb(187 247 208); 1559 + color: rgb(21 128 61); 1560 + } 1561 + 1562 + .untangled-home-vouch-action-btn.is-vouched:hover { 1563 + background: rgb(240 253 244); 1564 + } 1565 + 1566 + @media (prefers-color-scheme: dark) { 1567 + .untangled-home-vouch-action-btn { 1568 + border-color: rgb(75 85 99); 1569 + background: rgb(31 41 55); 1570 + color: rgb(209 213 219); 1571 + } 1572 + 1573 + .untangled-home-vouch-action-btn:hover { 1574 + background: rgb(55 65 81); 1575 + border-color: rgb(156 163 175); 1576 + } 1577 + 1578 + .untangled-home-vouch-action-btn.is-vouched { 1579 + background: rgb(20 83 45 / 0.4); 1580 + border-color: rgb(20 83 45); 1581 + color: rgb(74 222 128); 1582 + } 1583 + 1584 + .untangled-home-vouch-action-btn.is-vouched:hover { 1585 + background: rgb(20 83 45 / 0.6); 1586 + } 1587 + } 1588 + 1589 + .untangled-home-scroll-container { 1590 + display: flex; 1591 + flex-direction: column; 1592 + gap: 0.75rem; 1593 + max-height: 38rem; 1594 + overflow-y: auto; 1595 + padding-right: 0.5rem; 1596 + padding-bottom: 3rem; 1597 + transition: mask-image 0.2s, -webkit-mask-image 0.2s; 1598 + } 1599 + 1600 + /* 1. At the top (only fade at bottom) */ 1601 + .untangled-home-scroll-container.is-at-top { 1602 + mask-image: linear-gradient(to bottom, black 90%, transparent 100%); 1603 + -webkit-mask-image: linear-gradient(to bottom, black 90%, transparent 100%); 1604 + } 1605 + 1606 + /* 2. At the bottom (only fade at top) */ 1607 + .untangled-home-scroll-container.is-at-bottom { 1608 + mask-image: linear-gradient(to top, black 90%, transparent 100%); 1609 + -webkit-mask-image: linear-gradient(to top, black 90%, transparent 100%); 1610 + } 1611 + 1612 + /* 3. In the middle (fade at both top and bottom) */ 1613 + .untangled-home-scroll-container.is-in-middle { 1614 + mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%); 1615 + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%); 1616 + } 1617 + 1618 + /* 4. Not scrollable (no fade at all) */ 1619 + .untangled-home-scroll-container.is-not-scrollable { 1620 + mask-image: none; 1621 + -webkit-mask-image: none; 1622 + } 1623 + 1333 1624 .untangled-home-recent-strip, 1334 1625 .untangled-home-sample-list { 1335 1626 display: grid; ··· 1425 1716 background: rgb(31 41 55); 1426 1717 } 1427 1718 1719 + .untangled-home-section-header, 1720 + .untangled-home-feed-header { 1721 + background: rgb(17 24 39); 1722 + } 1723 + 1724 + .untangled-home-section-header h2, 1725 + .untangled-home-feed-title, 1428 1726 .untangled-home-activity-repo-label { 1429 1727 color: rgb(243 244 246); 1430 1728 } ··· 1446 1744 } 1447 1745 1448 1746 .untangled-home-repo-row-desc, 1747 + .untangled-home-repo-row-lang, 1449 1748 .untangled-home-repo-row-time, 1450 1749 .untangled-home-following-row small { 1451 - color: rgb(107 114 128); 1750 + color: rgb(156 163 175); 1452 1751 } 1453 1752 1454 1753 .untangled-home-brand p, ··· 2847 3146 border-right: 0; 2848 3147 } 2849 3148 } 3149 + 3150 + /* Lower grid section header background overrides */ 3151 + /* Lower grid section header background overrides */ 3152 + .untangled-home-below .untangled-home-section-header { 3153 + background: rgb(248 250 252) !important; 3154 + z-index: 15 !important; 3155 + } 3156 + 3157 + @media (prefers-color-scheme: dark) { 3158 + .untangled-home-below .untangled-home-section-header { 3159 + background: rgb(15 23 42) !important; 3160 + z-index: 15 !important; 3161 + } 3162 + } 3163 + 3164 + /* Homepage themed announcement cards */ 3165 + .untangled-home-promo-card-indigo { 3166 + border: 1px solid rgb(224 231 255); 3167 + background: linear-gradient(to bottom, #ffffff, rgb(240 244 255)); 3168 + color: rgb(67 56 202); 3169 + } 3170 + .untangled-home-promo-card-indigo h4 { 3171 + color: rgb(55 48 163); 3172 + } 3173 + .untangled-home-promo-card-indigo a { 3174 + color: rgb(67 56 202); 3175 + } 3176 + .untangled-home-promo-card-indigo a:hover { 3177 + color: rgb(49 46 129); 3178 + } 3179 + 3180 + .untangled-home-promo-card-green { 3181 + border: 1px solid rgb(209 250 229); 3182 + background: linear-gradient(to bottom, #ffffff, rgb(236 253 245)); 3183 + color: rgb(4 120 87); 3184 + } 3185 + .untangled-home-promo-card-green h4 { 3186 + color: rgb(6 95 70); 3187 + } 3188 + .untangled-home-promo-card-green a { 3189 + color: rgb(4 120 87); 3190 + } 3191 + .untangled-home-promo-card-green a:hover { 3192 + color: rgb(6 78 59); 3193 + } 3194 + 3195 + .untangled-home-promo-card-purple { 3196 + border: 1px solid rgb(243 232 255); 3197 + background: linear-gradient(to bottom, #ffffff, rgb(250 245 255)); 3198 + color: rgb(126 34 206); 3199 + } 3200 + .untangled-home-promo-card-purple h4 { 3201 + color: rgb(107 33 168); 3202 + } 3203 + .untangled-home-promo-card-purple a { 3204 + color: rgb(126 34 206); 3205 + } 3206 + .untangled-home-promo-card-purple a:hover { 3207 + color: rgb(88 28 135); 3208 + } 3209 + 3210 + .untangled-home-promo-card-blue { 3211 + border: 1px solid rgb(219 234 254); 3212 + background: linear-gradient(to bottom, #ffffff, rgb(239 246 255)); 3213 + color: rgb(37 99 235); 3214 + } 3215 + .untangled-home-promo-card-blue h4 { 3216 + color: rgb(26 86 219); 3217 + } 3218 + .untangled-home-promo-card-blue a { 3219 + color: rgb(37 99 235); 3220 + } 3221 + .untangled-home-promo-card-blue a:hover { 3222 + color: rgb(30 58 138); 3223 + } 3224 + 3225 + .untangled-home-promo-card-amber { 3226 + border: 1px solid rgb(254 243 199); 3227 + background: linear-gradient(to bottom, #ffffff, rgb(254 252 232)); 3228 + color: rgb(217 119 6); 3229 + } 3230 + .untangled-home-promo-card-amber h4 { 3231 + color: rgb(180 83 9); 3232 + } 3233 + .untangled-home-promo-card-amber a { 3234 + color: rgb(217 119 6); 3235 + } 3236 + .untangled-home-promo-card-amber a:hover { 3237 + color: rgb(120 53 4); 3238 + } 3239 + 3240 + .untangled-home-promo-card-rose { 3241 + border: 1px solid rgb(254 226 226); 3242 + background: linear-gradient(to bottom, #ffffff, rgb(255 241 241)); 3243 + color: rgb(225 29 72); 3244 + } 3245 + .untangled-home-promo-card-rose h4 { 3246 + color: rgb(190 24 74); 3247 + } 3248 + .untangled-home-promo-card-rose a { 3249 + color: rgb(225 29 72); 3250 + } 3251 + .untangled-home-promo-card-rose a:hover { 3252 + color: rgb(159 18 57); 3253 + } 3254 + 3255 + .untangled-home-promo-card-orange { 3256 + border: 1px solid rgb(254 237 222); 3257 + background: linear-gradient(to bottom, #ffffff, rgb(255 247 237)); 3258 + color: rgb(234 88 12); 3259 + } 3260 + .untangled-home-promo-card-orange h4 { 3261 + color: rgb(194 65 12); 3262 + } 3263 + .untangled-home-promo-card-orange a { 3264 + color: rgb(234 88 12); 3265 + } 3266 + .untangled-home-promo-card-orange a:hover { 3267 + color: rgb(154 52 18); 3268 + } 3269 + 3270 + .untangled-home-promo-card-teal { 3271 + border: 1px solid rgb(204 251 241); 3272 + background: linear-gradient(to bottom, #ffffff, rgb(240 253 250)); 3273 + color: rgb(13 148 136); 3274 + } 3275 + .untangled-home-promo-card-teal h4 { 3276 + color: rgb(15 118 110); 3277 + } 3278 + .untangled-home-promo-card-teal a { 3279 + color: rgb(13 148 136); 3280 + } 3281 + .untangled-home-promo-card-teal a:hover { 3282 + color: rgb(17 94 89); 3283 + } 3284 + 3285 + .untangled-home-promo-card-sky { 3286 + border: 1px solid rgb(224 242 254); 3287 + background: linear-gradient(to bottom, #ffffff, rgb(240 249 255)); 3288 + color: rgb(2 132 199); 3289 + } 3290 + .untangled-home-promo-card-sky h4 { 3291 + color: rgb(3 105 161); 3292 + } 3293 + .untangled-home-promo-card-sky a { 3294 + color: rgb(2 132 199); 3295 + } 3296 + .untangled-home-promo-card-sky a:hover { 3297 + color: rgb(7 89 133); 3298 + } 3299 + 3300 + .untangled-home-promo-card-pink { 3301 + border: 1px solid rgb(252 231 243); 3302 + background: linear-gradient(to bottom, #ffffff, rgb(253 242 248)); 3303 + color: rgb(219 39 119); 3304 + } 3305 + .untangled-home-promo-card-pink h4 { 3306 + color: rgb(190 24 93); 3307 + } 3308 + .untangled-home-promo-card-pink a { 3309 + color: rgb(219 39 119); 3310 + } 3311 + .untangled-home-promo-card-pink a:hover { 3312 + color: rgb(157 23 77); 3313 + } 3314 + 3315 + /* Dark mode overrides */ 3316 + @media (prefers-color-scheme: dark) { 3317 + .untangled-home-promo-card-indigo { 3318 + border-color: rgb(67 56 202 / 0.4); 3319 + background: linear-gradient(to bottom, rgb(30 27 75 / 0.3), rgb(30 27 75 / 0.5)); 3320 + color: rgb(165 180 252); 3321 + } 3322 + .untangled-home-promo-card-indigo h4 { 3323 + color: rgb(199 210 254); 3324 + } 3325 + .untangled-home-promo-card-indigo a { 3326 + color: rgb(165 180 252); 3327 + } 3328 + 3329 + .untangled-home-promo-card-green { 3330 + border-color: rgb(6 95 70 / 0.4); 3331 + background: linear-gradient(to bottom, rgb(6 78 59 / 0.2), rgb(6 78 59 / 0.4)); 3332 + color: rgb(110 231 183); 3333 + } 3334 + .untangled-home-promo-card-green h4 { 3335 + color: rgb(167 243 208); 3336 + } 3337 + .untangled-home-promo-card-green a { 3338 + color: rgb(110 231 183); 3339 + } 3340 + 3341 + .untangled-home-promo-card-purple { 3342 + border-color: rgb(126 34 206 / 0.4); 3343 + background: linear-gradient(to bottom, rgb(88 28 135 / 0.2), rgb(88 28 135 / 0.4)); 3344 + color: rgb(216 180 254); 3345 + } 3346 + .untangled-home-promo-card-purple h4 { 3347 + color: rgb(233 213 255); 3348 + } 3349 + .untangled-home-promo-card-purple a { 3350 + color: rgb(216 180 254); 3351 + } 3352 + 3353 + .untangled-home-promo-card-blue { 3354 + border-color: rgb(37 99 235 / 0.4); 3355 + background: linear-gradient(to bottom, rgb(30 58 138 / 0.2), rgb(30 58 138 / 0.4)); 3356 + color: rgb(147 197 253); 3357 + } 3358 + .untangled-home-promo-card-blue h4 { 3359 + color: rgb(191 219 254); 3360 + } 3361 + .untangled-home-promo-card-blue a { 3362 + color: rgb(147 197 253); 3363 + } 3364 + 3365 + .untangled-home-promo-card-amber { 3366 + border-color: rgb(217 119 6 / 0.4); 3367 + background: linear-gradient(to bottom, rgb(120 53 4 / 0.2), rgb(120 53 4 / 0.4)); 3368 + color: rgb(253 230 138); 3369 + } 3370 + .untangled-home-promo-card-amber h4 { 3371 + color: rgb(254 243 199); 3372 + } 3373 + .untangled-home-promo-card-amber a { 3374 + color: rgb(253 230 138); 3375 + } 3376 + 3377 + .untangled-home-promo-card-rose { 3378 + border-color: rgb(225 29 72 / 0.4); 3379 + background: linear-gradient(to bottom, rgb(136 19 55 / 0.2), rgb(136 19 55 / 0.4)); 3380 + color: rgb(244 63 94); 3381 + } 3382 + .untangled-home-promo-card-rose h4 { 3383 + color: rgb(251 113 133); 3384 + } 3385 + .untangled-home-promo-card-rose a { 3386 + color: rgb(244 63 94); 3387 + } 3388 + 3389 + .untangled-home-promo-card-orange { 3390 + border-color: rgb(234 88 12 / 0.4); 3391 + background: linear-gradient(to bottom, rgb(124 45 18 / 0.2), rgb(124 45 18 / 0.4)); 3392 + color: rgb(251 146 60); 3393 + } 3394 + .untangled-home-promo-card-orange h4 { 3395 + color: rgb(253 186 116); 3396 + } 3397 + .untangled-home-promo-card-orange a { 3398 + color: rgb(251 146 60); 3399 + } 3400 + 3401 + .untangled-home-promo-card-teal { 3402 + border-color: rgb(13 148 136 / 0.4); 3403 + background: linear-gradient(to bottom, rgb(17 94 89 / 0.2), rgb(17 94 89 / 0.4)); 3404 + color: rgb(45 212 191); 3405 + } 3406 + .untangled-home-promo-card-teal h4 { 3407 + color: rgb(94 234 212); 3408 + } 3409 + .untangled-home-promo-card-teal a { 3410 + color: rgb(45 212 191); 3411 + } 3412 + 3413 + .untangled-home-promo-card-sky { 3414 + border-color: rgb(2 132 199 / 0.4); 3415 + background: linear-gradient(to bottom, rgb(7 89 133 / 0.2), rgb(7 89 133 / 0.4)); 3416 + color: rgb(56 189 248); 3417 + } 3418 + .untangled-home-promo-card-sky h4 { 3419 + color: rgb(125 211 252); 3420 + } 3421 + .untangled-home-promo-card-sky a { 3422 + color: rgb(56 189 248); 3423 + } 3424 + 3425 + .untangled-home-promo-card-pink { 3426 + border-color: rgb(219 39 119 / 0.4); 3427 + background: linear-gradient(to bottom, rgb(157 23 77 / 0.2), rgb(157 23 77 / 0.4)); 3428 + color: rgb(244 114 182); 3429 + } 3430 + .untangled-home-promo-card-pink h4 { 3431 + color: rgb(247 178 208); 3432 + } 3433 + .untangled-home-promo-card-pink a { 3434 + color: rgb(244 114 182); 3435 + } 3436 + }
+82 -2
src/lib/repo-utils.ts
··· 22 22 owner: string; 23 23 slug: string; 24 24 title: string; 25 + description?: string; 26 + language?: string; 27 + stars?: number; 28 + openIssues?: number; 29 + openPRs?: number; 30 + updatedAt: string; 31 + } 32 + 33 + export interface RecentIssuePull { 34 + uri: string; 35 + kind: 'issue' | 'pull'; 36 + title: string; 37 + repoLabel: string; 38 + href: string; 39 + state: 'open' | 'closed' | 'merged'; 40 + number: number; 41 + author: string; 25 42 updatedAt: string; 26 43 } 27 44 ··· 198 215 } 199 216 }; 200 217 201 - export const saveRecentRepo = (repo: RepoContext) => { 218 + export const saveRecentRepo = ( 219 + repo: RepoContext, 220 + stats?: { stars?: number; openIssues?: number; openPRs?: number } 221 + ) => { 222 + const currentItems = loadRecentRepos(); 223 + const existing = currentItems.find((item) => item.owner === repo.owner.handle && item.slug === repo.slug); 202 224 const next: RecentRepo = { 203 225 owner: repo.owner.handle, 204 226 slug: repo.slug, 205 227 title: `${repo.owner.handle}/${repo.slug}`, 228 + description: repo.record.value.description?.trim() || undefined, 229 + language: existing?.language, 230 + stars: stats?.stars ?? existing?.stars, 231 + openIssues: stats?.openIssues ?? existing?.openIssues, 232 + openPRs: stats?.openPRs ?? existing?.openPRs, 206 233 updatedAt: new Date().toISOString(), 207 234 }; 208 - const items = loadRecentRepos().filter((item) => !(item.owner === next.owner && item.slug === next.slug)); 235 + const items = currentItems.filter((item) => !(item.owner === next.owner && item.slug === next.slug)); 209 236 items.unshift(next); 210 237 localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(items.slice(0, 8))); 238 + }; 239 + 240 + export const updateRecentRepoLanguage = (owner: string, slug: string, language: string) => { 241 + try { 242 + const items = loadRecentRepos(); 243 + const idx = items.findIndex((item) => item.owner === owner && item.slug === slug); 244 + if (idx !== -1) { 245 + items[idx] = { ...items[idx], language }; 246 + localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(items)); 247 + } 248 + } catch { 249 + // ignore 250 + } 251 + }; 252 + 253 + export const loadRecentIssuesPulls = (): RecentIssuePull[] => { 254 + try { 255 + const raw = localStorage.getItem('untangled.recentIssuesPulls'); 256 + if (!raw) { 257 + return []; 258 + } 259 + return JSON.parse(raw) as RecentIssuePull[]; 260 + } catch { 261 + return []; 262 + } 263 + }; 264 + 265 + export const saveRecentIssueOrPull = ( 266 + owner: string, 267 + slug: string, 268 + item: { 269 + uri: string; 270 + kind: 'issue' | 'pull'; 271 + title: string; 272 + number: number; 273 + state: 'open' | 'closed' | 'merged'; 274 + author: string; 275 + }, 276 + ) => { 277 + const next: RecentIssuePull = { 278 + uri: item.uri, 279 + kind: item.kind, 280 + title: item.title, 281 + repoLabel: `${owner}/${slug}`, 282 + href: `/${owner}/${slug}/${item.kind}s/${item.number || item.uri.split('/').pop()}`, 283 + state: item.state, 284 + number: item.number, 285 + author: item.author, 286 + updatedAt: new Date().toISOString(), 287 + }; 288 + const items = loadRecentIssuesPulls().filter((x) => x.uri !== next.uri); 289 + items.unshift(next); 290 + localStorage.setItem('untangled.recentIssuesPulls', JSON.stringify(items.slice(0, 10))); 211 291 }; 212 292 213 293 export const uniqueCommenters = (
+438 -179
src/pages/home.tsx
··· 1 - import clsx from 'clsx'; 2 1 import { 3 2 BookMarked, 4 3 CircleDot, ··· 6 5 GalleryVertical, 7 6 GitPullRequest, 8 7 LoaderCircle, 9 - Star, 10 - Users, 11 8 UserRound, 9 + TrendingUp, 10 + ShieldCheck, 11 + Shield, 12 + ShieldPlus, 13 + Megaphone, 12 14 } from 'lucide-solid'; 15 + import clsx from 'clsx'; 13 16 import { A } from '@solidjs/router'; 14 17 import { createQuery } from '@tanstack/solid-query'; 15 - import { For, Show, createEffect, createMemo, onCleanup, type Component, type JSX } from 'solid-js'; 18 + import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component, type JSX } from 'solid-js'; 16 19 17 - import { Avatar, ErrorState, LoadingState } from '../components/common'; 20 + import { Avatar, ErrorState, LoadingState, StateBadge } from '../components/common'; 21 + import { RepoCard } from '../components/repo'; 18 22 import { listFollowRecords } from '../lib/api/graph'; 19 23 import { resolveActor } from '../lib/api/identity'; 20 24 import { listRepoRecords, type RepoRecord } from '../lib/api/repos'; 21 25 import { useAuth } from '../lib/auth'; 22 26 import { useLiveEvents, type LiveEvent } from '../lib/live-events'; 23 - import { formatRelativeTime, loadRecentRepos } from '../lib/repo-utils'; 27 + import { formatRelativeTime, loadRecentRepos, loadRecentIssuesPulls } from '../lib/repo-utils'; 28 + 29 + const SAMPLE_REPOS: Array<{ 30 + owner: string; 31 + slug: string; 32 + description?: string; 33 + language?: string; 34 + stars?: number; 35 + forks?: number; 36 + openIssues?: number; 37 + openPRs?: number; 38 + }> = [ 39 + { owner: 'nekomimi.pet', slug: 'wisp.place-monorepo', description: 'Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.', language: 'TypeScript', stars: 94, openIssues: 39, openPRs: 23 }, 40 + { owner: 'mary.my.id', slug: 'atcute', description: 'a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky', language: 'TypeScript', stars: 101, openIssues: 8 }, 41 + { owner: 'pds.ls', slug: 'pdsls', description: 'atmosphere explorer', language: 'TypeScript', stars: 491, openIssues: 20, openPRs: 21 }, 42 + ]; 43 + 44 + const FALLBACK_ENTRIES = [ 45 + { 46 + title: 'tail CI logs over SSH', 47 + link: 'https://blog.tangled.org/ssh', 48 + summary: 'never leave your terminal to check on CI!', 49 + updated: '2026-05-28T00:00:00Z', 50 + author: 'Akshay' 51 + }, 52 + { 53 + title: 'combat LLM spam by building a web of trust', 54 + link: 'https://blog.tangled.org/vouching', 55 + summary: 'vouching on tangled!', 56 + updated: '2026-05-01T00:00:00Z', 57 + author: 'Akshay' 58 + }, 59 + { 60 + title: 'we need a federation of forges', 61 + link: 'https://blog.tangled.org/federation', 62 + summary: 'git is decentralized, but what of the rest?', 63 + updated: '2026-04-29T00:00:00Z', 64 + author: 'Akshay' 65 + }, 66 + { 67 + title: 'newsletter 01 — hello!', 68 + link: 'https://blog.tangled.org/newsletter-01', 69 + summary: 'kicking off our newsletter!', 70 + updated: '2026-04-16T00:00:00Z', 71 + author: 'Anirudh' 72 + }, 73 + { 74 + title: 'announcing our €3,8M seed round', 75 + link: 'https://blog.tangled.org/seed', 76 + summary: "and more on what's next", 77 + updated: '2026-03-02T00:00:00Z', 78 + author: 'Anirudh' 79 + } 80 + ]; 81 + 82 + const fetchBlogFeed = async (): Promise<any[]> => { 83 + try { 84 + const response = await fetch('https://blog.tangled.org/feed.xml'); 85 + if (!response.ok) throw new Error('Failed to fetch'); 86 + const text = await response.text(); 87 + const parser = new DOMParser(); 88 + const xmlDoc = parser.parseFromString(text, 'text/xml'); 89 + const entries = xmlDoc.getElementsByTagName('entry'); 90 + const posts = []; 91 + for (let i = 0; i < entries.length; i++) { 92 + const entry = entries[i]; 93 + const title = entry.getElementsByTagName('title')[0]?.textContent || ''; 94 + const updated = entry.getElementsByTagName('updated')[0]?.textContent || ''; 95 + const summary = entry.getElementsByTagName('summary')[0]?.textContent || ''; 96 + const authorName = entry.getElementsByTagName('author')[0]?.getElementsByTagName('name')[0]?.textContent || ''; 97 + 98 + let href = ''; 99 + const links = entry.getElementsByTagName('link'); 100 + for (let j = 0; j < links.length; j++) { 101 + const link = links[j]; 102 + if (link.getAttribute('rel') === 'alternate' || !link.getAttribute('rel')) { 103 + href = link.getAttribute('href') || ''; 104 + break; 105 + } 106 + } 107 + posts.push({ 108 + title, 109 + link: href || 'https://blog.tangled.org', 110 + summary, 111 + updated, 112 + author: authorName, 113 + }); 114 + } 115 + return posts; 116 + } catch (err) { 117 + console.warn('CORS or network error fetching blog feed, using fallback', err); 118 + return FALLBACK_ENTRIES; 119 + } 120 + }; 121 + 122 + const formatBlogDate = (dateStr: string) => { 123 + try { 124 + const date = new Date(dateStr); 125 + return date.toLocaleDateString('en-US', { 126 + month: 'short', 127 + day: 'numeric', 128 + year: 'numeric' 129 + }); 130 + } catch { 131 + return dateStr; 132 + } 133 + }; 24 134 25 - const SAMPLE_REPOS = [ 26 - { owner: 'mary.my.id', slug: 'atcute' }, 27 - { owner: 'mary.my.id', slug: 'boat' }, 28 - { owner: 'mary.my.id', slug: 'aglais' }, 135 + const ANNOUNCEMENT_COLORS = [ 136 + 'untangled-home-promo-card-indigo', 137 + 'untangled-home-promo-card-green', 138 + 'untangled-home-promo-card-purple', 139 + 'untangled-home-promo-card-blue', 140 + 'untangled-home-promo-card-amber' 29 141 ]; 30 142 143 + const hashStringToIndex = (str: string, count: number): number => { 144 + let hash = 0; 145 + for (let i = 0; i < str.length; i++) { 146 + hash = str.charCodeAt(i) + ((hash << 5) - hash); 147 + } 148 + return Math.abs(hash) % count; 149 + }; 150 + 151 + const AnnouncementCard: Component<{ 152 + title: string; 153 + link: string; 154 + summary: string; 155 + updated: string; 156 + author: string; 157 + cardClass: string; 158 + }> = (props) => { 159 + const formattedDate = () => formatBlogDate(props.updated); 160 + 161 + return ( 162 + <div class={clsx('relative overflow-hidden border rounded-sm p-6', props.cardClass)}> 163 + <div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,rgba(255,255,255,0.25),transparent_70%)]"></div> 164 + 165 + <div class="relative z-10 flex flex-col gap-1 text-inherit"> 166 + <div class="flex items-center justify-between text-xs opacity-75 mb-1 text-inherit"> 167 + <span>{props.author}</span> 168 + <span>{formattedDate()}</span> 169 + </div> 170 + <h4 class="font-bold mb-1 text-base leading-snug text-inherit"> 171 + {props.title} 172 + </h4> 173 + <p class="text-sm margin-0 opacity-90 text-inherit"> 174 + {props.summary} 175 + </p> 176 + <div class="mt-3 flex items-center justify-between"> 177 + <a 178 + class="no-underline inline-flex items-center gap-1 text-sm font-semibold hover:underline text-inherit" 179 + href={props.link} 180 + target="_blank" 181 + rel="noopener noreferrer" 182 + > 183 + Read more &rarr; 184 + </a> 185 + </div> 186 + </div> 187 + </div> 188 + ); 189 + }; 190 + 31 191 const HOME_ACTIVITY_SOURCES = [ 32 192 'sh.tangled.repo.issue:repoDid', 33 193 'sh.tangled.repo.issue:repo', ··· 56 216 at?: string; 57 217 body?: string; 58 218 actorDid?: string; 219 + actorHandle?: string; 59 220 }; 60 221 61 222 type FollowingHomeData = { ··· 78 239 const timestamp = item.at ? new Date(item.at).getTime() : 0; 79 240 return Number.isFinite(timestamp) ? timestamp : 0; 80 241 }; 81 - 82 - const formatCount = (count: number, singular: string, plural = `${singular}s`) => 83 - `${count} ${count === 1 ? singular : plural}`; 84 242 85 243 const homeRepoItem = (owner: string, repo: RepoRecord): HomeRepoItem => { 86 244 const slug = repoSlug(repo); ··· 149 307 at: repoItem.createdAt, 150 308 body: repoItem.description, 151 309 actorDid: item.actor.did, 310 + actorHandle: item.actor.handle, 152 311 }; 153 312 }), 154 313 ) ··· 162 321 const auth = useAuth(); 163 322 const live = useLiveEvents(); 164 323 const recentRepos = createMemo(loadRecentRepos); 324 + const recentIssuesPulls = createMemo(loadRecentIssuesPulls); 325 + 326 + 165 327 166 328 const ownReposQuery = createQuery(() => ({ 167 329 queryKey: ['own-repos', auth.currentDid()], ··· 179 341 queryFn: async () => fetchFollowingHomeData(auth.currentDid()!), 180 342 })); 181 343 344 + const [vouchedHandles, setVouchedHandles] = createSignal<string[]>([]); 345 + 346 + const vouchSuggestionsQuery = createQuery(() => ({ 347 + queryKey: ['vouch-suggestions'], 348 + queryFn: async () => { 349 + const handles = ['nekomimi.pet', 'mary.my.id', 'juliet.paris']; 350 + const resolved = await Promise.all( 351 + handles.map(async (handle) => { 352 + try { 353 + const actor = await resolveActor(handle); 354 + return { 355 + did: actor.did, 356 + handle: actor.handle, 357 + }; 358 + } catch { 359 + return { 360 + did: `did:placeholder:${handle}`, 361 + handle, 362 + }; 363 + } 364 + }) 365 + ); 366 + return resolved; 367 + } 368 + })); 369 + 370 + const blogQuery = createQuery(() => ({ 371 + queryKey: ['blog-feed'], 372 + queryFn: fetchBlogFeed, 373 + })); 374 + 375 + 182 376 const ownRepoItems = createMemo(() => { 183 377 const data = ownReposQuery.data; 184 378 if (!data) return []; ··· 186 380 .map((repo) => homeRepoItem(data.actor.handle, repo)) 187 381 .sort((left, right) => repoTimestamp(right) - repoTimestamp(left)); 188 382 }); 383 + 384 + const currentUserHandle = () => ownReposQuery.data?.actor.handle || ''; 385 + const currentUserDid = () => auth.currentDid() || ''; 189 386 190 387 const repoByDid = createMemo(() => { 191 388 const repos = new Map<string, HomeRepoItem>(); ··· 224 421 }); 225 422 }); 226 423 424 + 425 + 227 426 const activityItems = createMemo<HomeActivityItem[]>(() => { 228 427 const repos = repoByDid(); 229 428 const liveItems = live.events() ··· 237 436 repoLabel: repo.title, 238 437 href: eventActivityHref(repo, event), 239 438 at: event.receivedAt, 439 + actorDid: currentUserDid(), 440 + actorHandle: currentUserHandle(), 240 441 }; 241 442 }) 242 443 .filter((item): item is HomeActivityItem => Boolean(item)); ··· 249 450 href: repo.href, 250 451 at: repo.createdAt, 251 452 body: repo.description, 453 + actorDid: currentUserDid(), 454 + actorHandle: currentUserHandle(), 252 455 })); 253 456 254 457 return [...liveItems, ...repoItems] ··· 259 462 return ( 260 463 <div class="untangled-home-page"> 261 464 <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 465 <main class="untangled-home-feed"> 299 466 <div class="untangled-home-feed-header"> 300 467 <h2 class="untangled-home-feed-title"> 301 468 <GalleryVertical class="size-5" /> 302 469 activity 303 470 </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> 311 471 </div> 312 472 313 473 <Show ··· 337 497 </Show> 338 498 </main> 339 499 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 - > 500 + <aside class="untangled-home-side untangled-home-side-right flex flex-col gap-6"> 501 + {/* Recent Repos */} 502 + <div class="flex flex-col gap-3 w-full"> 503 + <HomeSectionHeader icon={<BookMarked class="size-5 text-gray-500" />} title="recent" /> 504 + <Show 505 + when={recentRepos().length > 0} 506 + fallback={<HomeEmpty label="No recent repositories." />} 507 + > 508 + <div class="flex flex-col gap-3"> 509 + <For each={recentRepos().slice(0, 5)}> 510 + {(repo) => ( 511 + <RepoCard 512 + href={`/${repo.owner}/${repo.slug}`} 513 + owner={repo.owner} 514 + name={repo.slug} 515 + description={repo.description} 516 + language={repo.language} 517 + stars={repo.stars} 518 + openIssues={repo.openIssues} 519 + openPRs={repo.openPRs} 520 + compact 521 + /> 522 + )} 523 + </For> 524 + </div> 525 + </Show> 526 + </div> 527 + 528 + {/* Recent Issues / PRs */} 529 + <div class="flex flex-col gap-3 w-full"> 530 + <HomeSectionHeader icon={<CircleDot class="size-5" />} title="recent issues / prs" /> 346 531 <Show 347 - when={!followingQuery.isLoading} 348 - fallback={<LoadingState label="Loading following..." />} 532 + when={recentIssuesPulls().length > 0} 533 + fallback={<HomeEmpty label="No recent issues or pull requests." />} 349 534 > 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> 535 + <div class="untangled-home-compact-list"> 536 + <For each={recentIssuesPulls().slice(0, 5)}> 537 + {(item) => ( 538 + <A href={item.href} class="untangled-home-repo-row"> 539 + <div class="text-sm font-semibold text-black dark:text-white truncate"> 540 + {item.title}{' '} 541 + <span class="text-gray-500 dark:text-gray-400 font-normal">#{item.number}</span> 542 + </div> 543 + <div class="mt-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 min-w-0"> 544 + <StateBadge state={item.state} kind={item.kind} /> 545 + <span class="truncate min-w-0">{item.author} · {item.repoLabel}</span> 546 + </div> 547 + </A> 548 + )} 549 + </For> 550 + </div> 373 551 </Show> 374 - </Show> 552 + </div> 375 553 </aside> 376 554 </section> 377 555 378 556 <section class="untangled-home-below"> 379 - <div class="untangled-home-wide-section"> 380 - <HomeSectionHeader icon={<Star class="size-4" />} title="recent repositories" /> 381 - <Show 382 - when={recentRepos().length > 0} 383 - fallback={<HomeEmpty label="No recent repositories." />} 384 - > 385 - <div class="untangled-home-recent-strip"> 386 - <For each={recentRepos()}> 387 - {(repo) => ( 388 - <A href={`/${repo.owner}/${repo.slug}`} class="untangled-home-recent-link"> 389 - <span>{repo.title}</span> 390 - <small>{formatRelativeTime(repo.updatedAt)}</small> 391 - </A> 392 - )} 393 - </For> 557 + <div class="untangled-home-lower-grid"> 558 + {/* Left sidebar announcements */} 559 + <aside class="untangled-home-side untangled-home-promos-column"> 560 + {/* Announcements Section */} 561 + <div class="flex flex-col gap-3 w-full"> 562 + <HomeSectionHeader icon={<Megaphone class="size-5" />} title="announcements" /> 563 + <div class="untangled-home-announcements-grid"> 564 + <Show when={!blogQuery.isLoading} fallback={<LoadingState label="Loading announcements..." />}> 565 + <For each={blogQuery.data?.slice(0, 5) ?? []}> 566 + {(post) => { 567 + const colorIndex = hashStringToIndex(post.title, ANNOUNCEMENT_COLORS.length); 568 + const cardClass = ANNOUNCEMENT_COLORS[colorIndex]; 569 + return ( 570 + <AnnouncementCard 571 + title={post.title} 572 + link={post.link} 573 + summary={post.summary} 574 + updated={post.updated} 575 + author={post.author} 576 + cardClass={cardClass} 577 + /> 578 + ); 579 + }} 580 + </For> 581 + </Show> 582 + </div> 394 583 </div> 395 - </Show> 396 - </div> 584 + </aside> 397 585 398 - <div class="untangled-home-lower-grid"> 399 - <section class="untangled-home-wide-section"> 400 - <HomeSectionHeader icon={<UserRound class="size-4" />} title="following activity" /> 586 + <section class="untangled-home-wide-section untangled-home-lower-left"> 587 + <HomeSectionHeader icon={<UserRound class="size-5" />} title="following activity" /> 401 588 <Show 402 589 when={auth.currentDid()} 403 590 fallback={<HomeEmpty label="Sign in to see followed repository activity." />} ··· 425 612 </Show> 426 613 </section> 427 614 428 - <section class="untangled-home-wide-section"> 429 - <HomeSectionHeader icon={<FolderCode class="size-4" />} title="sample repositories" /> 430 - <HomeSampleRepos compact /> 431 - </section> 615 + <aside class="untangled-home-side flex flex-col gap-6 untangled-home-lower-right"> 616 + <section class="untangled-home-wide-section"> 617 + <HomeSectionHeader icon={<TrendingUp class="size-5" />} title="trending" /> 618 + <div class="flex flex-col gap-3"> 619 + <For each={SAMPLE_REPOS}> 620 + {(repo) => ( 621 + <RepoCard 622 + href={`/${repo.owner}/${repo.slug}`} 623 + owner={repo.owner} 624 + name={repo.slug} 625 + description={repo.description} 626 + language={repo.language} 627 + stars={repo.stars} 628 + forks={repo.forks} 629 + openIssues={repo.openIssues} 630 + compact 631 + /> 632 + )} 633 + </For> 634 + </div> 635 + </section> 636 + 637 + {/* Suggested (Vouches) Section */} 638 + <div class="flex flex-col gap-3 w-full"> 639 + <header class="untangled-home-section-header flex justify-between items-center w-full"> 640 + <div class="flex items-center gap-3"> 641 + <ShieldPlus class="size-5" /> 642 + <h2>suggested</h2> 643 + </div> 644 + <Show when={auth.currentDid()}> 645 + <A href={`/${ownReposQuery.data?.actor.handle || ownReposQuery.data?.actor.did || ''}?tab=vouches`} class="text-sm font-normal hover:underline text-gray-500 dark:text-gray-400"> 646 + view all &rarr; 647 + </A> 648 + </Show> 649 + </header> 650 + <div class="flex flex-col gap-4 bg-white dark:bg-gray-800 p-4 border border-gray-200 dark:border-gray-700 shadow-sm rounded-sm"> 651 + <Show when={!vouchSuggestionsQuery.isLoading} fallback={<LoadingState label="Loading suggestions..." />}> 652 + <For each={vouchSuggestionsQuery.data ?? []}> 653 + {(suggestion) => { 654 + const isVouched = () => vouchedHandles().includes(suggestion.handle); 655 + const toggleVouch = () => { 656 + if (!auth.currentDid()) { 657 + alert('Please sign in to vouch for users.'); 658 + return; 659 + } 660 + if (isVouched()) { 661 + setVouchedHandles(prev => prev.filter(h => h !== suggestion.handle)); 662 + } else { 663 + setVouchedHandles(prev => [...prev, suggestion.handle]); 664 + } 665 + }; 666 + return ( 667 + <div class="flex items-center gap-3"> 668 + <Avatar did={suggestion.did} size="size-10" /> 669 + <div class="flex flex-col min-w-0 flex-1"> 670 + <a href={`https://tangled.org/${suggestion.handle}`} target="_blank" rel="noopener noreferrer" class="text-sm font-medium dark:text-white hover:underline truncate"> 671 + {suggestion.handle} 672 + </a> 673 + <span class="text-xs text-gray-500 dark:text-gray-400 truncate"> 674 + &nbsp; 675 + </span> 676 + </div> 677 + <div class="shrink-0"> 678 + <button 679 + type="button" 680 + onClick={toggleVouch} 681 + class={isVouched() ? 'btn-create text-xs px-2.5 py-1.5' : 'btn text-xs px-2.5 py-1.5'} 682 + > 683 + <Show when={isVouched()} fallback={<><Shield class="size-3.5 mr-1 inline" /> vouch</>}> 684 + <ShieldCheck class="size-3.5 mr-1 inline" /> vouched 685 + </Show> 686 + </button> 687 + </div> 688 + </div> 689 + ); 690 + }} 691 + </For> 692 + </Show> 693 + </div> 694 + </div> 695 + </aside> 432 696 </div> 433 697 </section> 434 698 </div> ··· 462 726 </div> 463 727 ); 464 728 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 - ); 729 + const ActivityRow: Component<{ item: HomeActivityItem; index: number }> = (props) => { 730 + const repoUrl = () => `/${props.item.repoLabel}`; 731 + const actorUrl = () => props.item.actorHandle ? `/${props.item.actorHandle}` : '#'; 732 + 733 + const actionText = () => { 734 + if (props.item.kind === 'repo') return 'published repository'; 735 + if (props.item.kind === 'issue') return 'created issue in'; 736 + if (props.item.kind === 'pull') return 'opened pull request in'; 737 + return 'updated'; 738 + }; 484 739 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"> 740 + return ( 741 + <div class="relative"> 742 + <Show when={props.index > 0}> 743 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600" /> 744 + </Show> 745 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 746 + {/* Header row */} 747 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 496 748 <Show when={props.item.actorDid}> 497 - <Avatar did={props.item.actorDid!} size="size-4" /> 749 + <Avatar did={props.item.actorDid!} size="size-5" /> 498 750 </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> 751 + <A href={actorUrl()} class="font-bold no-underline hover:underline text-black dark:text-white"> 752 + {props.item.actorHandle || 'user'} 753 + </A> 754 + <span>{actionText()}</span> 755 + <A href={repoUrl()} class="no-underline hover:underline font-bold text-black dark:text-white"> 756 + {props.item.repoLabel} 757 + </A> 758 + <span class="text-gray-400 dark:text-gray-500 text-xs ml-auto pr-6"> 759 + {formatRelativeTime(props.item.at)} 760 + </span> 761 + </div> 762 + 763 + {/* Content row */} 764 + <Show 765 + when={props.item.kind === 'repo'} 766 + fallback={ 767 + <div class="py-4 px-6 flex flex-col gap-1 bg-white dark:bg-gray-800"> 768 + <div class="font-medium text-black dark:text-white flex items-center"> 769 + <Show when={props.item.kind === 'pull'} fallback={<CircleDot class="w-4 h-4 mr-1.5 shrink-0 text-green-600" />}> 770 + <GitPullRequest class="w-4 h-4 mr-1.5 shrink-0 text-purple-600" /> 771 + </Show> 772 + <A href={props.item.href} class="no-underline hover:underline font-medium text-black dark:text-white text-base"> 773 + {props.item.title} 774 + </A> 775 + </div> 776 + <Show when={props.item.body}> 777 + <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2 mt-1"> 778 + {props.item.body} 779 + </div> 780 + </Show> 781 + </div> 782 + } 783 + > 784 + <RepoCard 785 + owner={props.item.repoLabel.split('/')[0]} 786 + name={props.item.repoLabel.split('/')[1]} 787 + description={props.item.body} 788 + href={props.item.href} 789 + noBorder 790 + /> 791 + </Show> 503 792 </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 793 </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 - ); 794 + ); 795 + }; 525 796 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 - );
+11 -1
src/pages/repo/code.tsx
··· 7 7 import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; 8 8 import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoLanguageBarSkeleton, RepoTreeSkeleton } from '../../components/repo'; 9 9 import { RepoFrame, useRepoQuery } from './shared'; 10 - import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatLanguagePercent, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, languageColor, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, videoLike } from '../../lib/repo-utils'; 10 + import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatLanguagePercent, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, languageColor, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, updateRecentRepoLanguage, videoLike } from '../../lib/repo-utils'; 11 11 import { LOADING_DELAY_MS, hasNamedRef, normalizeLanguages, resolveDefaultBranchName, resolveRouteRefAndPath, shouldAnimateNavigation, useDelayedLoading } from './code-helpers'; 12 12 13 13 const PathBreadcrumbs: Component<{ ··· 519 519 520 520 createEffect(() => { 521 521 if (!treeQuery.isFetching) setPendingTreeHref(undefined); 522 + }); 523 + 524 + createEffect(() => { 525 + const repo = repoQuery.data; 526 + const langs = languagesQuery.data; 527 + if (repo && langs?.languages?.length) { 528 + const normalized = normalizeLanguages(langs.languages, langs.totalSize); 529 + const top = normalized[0]?.name; 530 + if (top) updateRecentRepoLanguage(repo.owner.handle, repo.slug, top); 531 + } 522 532 }); 523 533 524 534 const handleFileClick = (
+16 -1
src/pages/repo/issues.tsx
··· 12 12 import { Avatar, ErrorState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common'; 13 13 import { AtUriPanel, MarkdownBlock, RepoFormSkeleton, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 14 14 import { RepoFrame, issueQueryKey, issuesQueryKey, useRepoQuery } from './shared'; 15 - import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, issueHref, parseIntegerSearchParam, uniqueCommenters } from '../../lib/repo-utils'; 15 + import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, issueHref, parseIntegerSearchParam, uniqueCommenters, saveRecentIssueOrPull } from '../../lib/repo-utils'; 16 16 import { buildIssueCommentThreads, buildIssueFilterQuery, parseIssueFilter } from './issues-helpers'; 17 17 18 18 export const IssuesPage: Component = () => { ··· 317 317 enabled: Boolean(repo && issueRef()), 318 318 queryFn: async () => getIssue(repo!, issueRef()), 319 319 }; 320 + }); 321 + 322 + createEffect(() => { 323 + const repo = repoQuery.data; 324 + const detail = issueQuery.data; 325 + if (repo && detail) { 326 + saveRecentIssueOrPull(repo.owner.handle, repo.slug, { 327 + uri: detail.issue.uri, 328 + kind: 'issue', 329 + title: detail.issue.value.title, 330 + number: detail.issue.number, 331 + state: detail.issue.state, 332 + author: detail.issue.author.handle, 333 + }); 334 + } 320 335 }); 321 336 322 337 const withIssueInvalidation = async (repo: RepoContext, issueRef: string, issueUri: string) => {
+16 -1
src/pages/repo/pulls.tsx
··· 26 26 import { Avatar, ErrorState, LoadingState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; 27 27 import { AtUriPanel, BranchPill, CommentCard, DiffView, MarkdownBlock, PullComposeSkeleton, PullDiffSkeleton, PullDiffView, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 28 28 import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery } from './shared'; 29 - import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters } from '../../lib/repo-utils'; 29 + import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters, saveRecentIssueOrPull } from '../../lib/repo-utils'; 30 30 import { buildPullFilterQuery, parsePastedPatchPrefill, parsePullFilter, type PullSourceMode } from './pulls-helpers'; 31 31 32 32 export const PullsPage: Component = () => { ··· 804 804 const rounds = pullQuery.data?.pull.value.rounds.length ?? 0; 805 805 if (rounds > 0) { 806 806 setRoundIndex(rounds - 1); 807 + } 808 + }); 809 + 810 + createEffect(() => { 811 + const repo = repoQuery.data; 812 + const detail = pullQuery.data; 813 + if (repo && detail) { 814 + saveRecentIssueOrPull(repo.owner.handle, repo.slug, { 815 + uri: detail.pull.uri, 816 + kind: 'pull', 817 + title: detail.pull.value.title, 818 + number: detail.pull.number, 819 + state: detail.pull.state, 820 + author: detail.pull.author.handle, 821 + }); 807 822 } 808 823 }); 809 824
+5 -1
src/pages/repo/shared.tsx
··· 179 179 return; 180 180 } 181 181 182 - saveRecentRepo(repo); 182 + saveRecentRepo(repo, { 183 + stars: starSummaryQuery.data?.count, 184 + openIssues: issueCountQuery.data, 185 + openPRs: pullCountQuery.data, 186 + }); 183 187 live.subscribe({ 184 188 subjects: [repo.repoDid], 185 189 sources: [