this repo has no description
0
fork

Configure Feed

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

feat(profile): add screenshots and reviews

Adds detail-page screenshots, review/report flows, and simpler public category badges so profile pages can present richer project context.

Made-with: Cursor

+3522 -59
+460
assets/styles.css
··· 1970 1970 } 1971 1971 .profile-card-meta { 1972 1972 display: flex; 1973 + flex-direction: column; 1974 + align-items: flex-start; 1973 1975 flex-wrap: wrap; 1974 1976 gap: 0.4rem; 1975 1977 margin: 0.6rem 0 0; 1976 1978 font-size: 0.78rem; 1977 1979 } 1978 1980 .profile-card-category, .profile-card-sub { 1981 + display: inline-flex; 1982 + align-items: center; 1979 1983 padding: 0.15rem 0.55rem; 1980 1984 border-radius: 999px; 1981 1985 background: rgba(255, 255, 255, 0.5); 1982 1986 color: rgba(18, 26, 47, 0.78); 1983 1987 border: 1px solid rgba(255, 255, 255, 0.5); 1984 1988 } 1989 + .profile-card-category { 1990 + background: rgba(42, 90, 168, 0.12); 1991 + border-color: rgba(42, 90, 168, 0.26); 1992 + color: #254a9e; 1993 + font-size: 0.76rem; 1994 + font-weight: 700; 1995 + letter-spacing: 0.04em; 1996 + text-transform: uppercase; 1997 + } 1998 + .profile-card-categories { 1999 + display: flex; 2000 + flex-wrap: wrap; 2001 + gap: 0.35rem; 2002 + } 2003 + .profile-card-subcategories { 2004 + display: flex; 2005 + flex-wrap: wrap; 2006 + gap: 0.35rem; 2007 + } 2008 + .profile-card-sub { 2009 + font-size: 0.76rem; 2010 + } 1985 2011 .dark-phase .profile-card-category, .dark-phase .profile-card-sub { 1986 2012 background: rgba(255, 255, 255, 0.1); 1987 2013 color: rgba(255, 255, 255, 0.85); 1988 2014 border-color: rgba(255, 255, 255, 0.15); 2015 + } 2016 + .dark-phase .profile-card-category { 2017 + background: rgba(106, 149, 255, 0.18); 2018 + border-color: rgba(166, 191, 255, 0.24); 2019 + color: #d8e4ff; 1989 2020 } 1990 2021 .profile-badge { 1991 2022 display: inline-flex; ··· 2185 2216 } 2186 2217 .profile-hero-meta { 2187 2218 display: flex; 2219 + flex-direction: column; 2220 + align-items: flex-start; 2188 2221 gap: 0.4rem; 2189 2222 flex-wrap: wrap; 2190 2223 margin-top: 0.5rem; ··· 2291 2324 color: rgba(255, 255, 255, 0.85); 2292 2325 } 2293 2326 2327 + .profile-screenshots { 2328 + margin-top: 1.5rem; 2329 + } 2330 + .profile-screenshots-shell { 2331 + position: relative; 2332 + width: 100%; 2333 + min-width: 0; 2334 + } 2335 + .profile-screenshots-carousel { 2336 + display: flex; 2337 + gap: 1rem; 2338 + overflow-x: auto; 2339 + overscroll-behavior-x: contain; 2340 + scroll-snap-type: x proximity; 2341 + scrollbar-gutter: stable; 2342 + padding: 0.1rem 0 1rem; 2343 + -webkit-overflow-scrolling: touch; 2344 + width: 100%; 2345 + min-width: 0; 2346 + } 2347 + .profile-screenshots-arrow { 2348 + position: absolute; 2349 + top: 50%; 2350 + transform: translateY(-50%); 2351 + z-index: 2; 2352 + width: 42px; 2353 + height: 42px; 2354 + border-radius: 999px; 2355 + border: 1px solid rgba(255, 255, 255, 0.55); 2356 + background: rgba(255, 255, 255, 0.68); 2357 + color: #254a9e; 2358 + font: inherit; 2359 + font-size: 1.2rem; 2360 + cursor: pointer; 2361 + display: inline-flex; 2362 + align-items: center; 2363 + justify-content: center; 2364 + box-shadow: 0 10px 26px rgba(20, 34, 70, 0.1); 2365 + transition: background 0.15s ease, transform 0.15s ease; 2366 + } 2367 + .profile-screenshots-arrow--prev { 2368 + left: 0.65rem; 2369 + } 2370 + .profile-screenshots-arrow--next { 2371 + right: 0.65rem; 2372 + } 2373 + .profile-screenshots-arrow:hover { 2374 + background: rgba(255, 255, 255, 0.9); 2375 + transform: translateY(-50%) translateY(-1px); 2376 + } 2377 + .profile-screenshots-arrow:disabled { 2378 + opacity: 0.42; 2379 + cursor: default; 2380 + pointer-events: none; 2381 + } 2382 + .profile-screenshot-card { 2383 + display: flex; 2384 + align-items: center; 2385 + justify-content: center; 2386 + flex: 0 0 auto; 2387 + scroll-snap-align: start; 2388 + overflow: hidden; 2389 + height: clamp(320px, 58vh, 620px); 2390 + max-width: 100%; 2391 + border-radius: 20px; 2392 + background: rgba(255, 255, 255, 0.45); 2393 + border: 1px solid rgba(255, 255, 255, 0.55); 2394 + box-shadow: 0 16px 40px rgba(20, 34, 70, 0.12); 2395 + } 2396 + .profile-screenshot-img { 2397 + height: 100%; 2398 + width: auto; 2399 + max-width: 100%; 2400 + object-fit: contain; 2401 + display: block; 2402 + transition: transform 0.2s ease; 2403 + } 2404 + .profile-screenshot-card:hover .profile-screenshot-img { 2405 + transform: scale(1.015); 2406 + } 2407 + .dark-phase .profile-screenshot-card { 2408 + background: rgba(255, 255, 255, 0.08); 2409 + border-color: rgba(255, 255, 255, 0.14); 2410 + } 2411 + .dark-phase .profile-screenshots-arrow { 2412 + background: rgba(255, 255, 255, 0.1); 2413 + border-color: rgba(255, 255, 255, 0.16); 2414 + color: #f0f4ff; 2415 + } 2416 + .dark-phase .profile-screenshots-arrow:hover { 2417 + background: rgba(255, 255, 255, 0.16); 2418 + } 2419 + @media (max-width: 720px) { 2420 + .profile-screenshots-shell { 2421 + grid-template-columns: 1fr; 2422 + } 2423 + .profile-screenshots-arrow { 2424 + display: none; 2425 + } 2426 + .profile-screenshot-card { 2427 + height: clamp(300px, 62vh, 560px); 2428 + max-width: 82vw; 2429 + } 2430 + .profile-screenshot-img { 2431 + max-width: 82vw; 2432 + } 2433 + } 2434 + 2294 2435 .profile-footer { 2295 2436 margin-top: 2rem; 2296 2437 font-size: 0.85rem; ··· 2446 2587 @media (max-width: 720px) { 2447 2588 .profile-form-mobile-links { 2448 2589 grid-template-columns: 1fr; 2590 + } 2591 + } 2592 + .profile-form-section-heading { 2593 + display: flex; 2594 + align-items: center; 2595 + justify-content: space-between; 2596 + gap: 1rem; 2597 + } 2598 + .profile-form-count { 2599 + font-family: "IBM Plex Mono", monospace; 2600 + font-size: 0.8rem; 2601 + color: rgba(18, 26, 47, 0.52); 2602 + } 2603 + .profile-screenshots-field { 2604 + gap: 0.75rem; 2605 + } 2606 + .profile-screenshot-status { 2607 + width: fit-content; 2608 + } 2609 + .profile-screenshot-grid { 2610 + display: grid; 2611 + grid-template-columns: repeat(4, minmax(0, 1fr)); 2612 + gap: 0.75rem; 2613 + } 2614 + .profile-screenshot-edit { 2615 + position: relative; 2616 + overflow: hidden; 2617 + aspect-ratio: 16 / 10; 2618 + border-radius: 16px; 2619 + border: 1px solid rgba(18, 26, 47, 0.1); 2620 + background: rgba(255, 255, 255, 0.42); 2621 + } 2622 + .profile-screenshot-edit-img { 2623 + width: 100%; 2624 + height: 100%; 2625 + object-fit: cover; 2626 + display: block; 2627 + } 2628 + .profile-screenshot-remove { 2629 + position: absolute; 2630 + top: 0.45rem; 2631 + right: 0.45rem; 2632 + width: 28px; 2633 + height: 28px; 2634 + border-radius: 999px; 2635 + border: 1px solid rgba(255, 255, 255, 0.66); 2636 + background: rgba(18, 26, 47, 0.78); 2637 + color: #ffffff; 2638 + cursor: pointer; 2639 + } 2640 + .profile-screenshot-native-picker { 2641 + max-width: 520px; 2642 + } 2643 + .profile-screenshot-file-input { 2644 + padding: 0.7rem; 2645 + cursor: pointer; 2646 + } 2647 + .dark-phase .profile-form-count { 2648 + color: rgba(255, 255, 255, 0.58); 2649 + } 2650 + .dark-phase .profile-screenshot-edit { 2651 + border-color: rgba(255, 255, 255, 0.14); 2652 + background: rgba(255, 255, 255, 0.08); 2653 + } 2654 + @media (max-width: 720px) { 2655 + .profile-screenshot-grid { 2656 + grid-template-columns: repeat(2, minmax(0, 1fr)); 2449 2657 } 2450 2658 } 2451 2659 ··· 3226 3434 } 3227 3435 .profile-form-status--error { 3228 3436 color: #c25048; 3437 + } 3438 + .profile-form-hydration-note { 3439 + margin: 0.75rem 0 0; 3440 + font-size: 0.85rem; 3441 + color: rgba(194, 80, 72, 0.9); 3229 3442 } 3230 3443 3231 3444 /* ---- Sign-in form ---- */ ··· 4682 4895 .dark-phase .profile-report-button { 4683 4896 border-color: rgba(255, 255, 255, 0.18); 4684 4897 color: rgba(255, 255, 255, 0.65); 4898 + } 4899 + .profile-reviews-shell { 4900 + display: flex; 4901 + flex-direction: column; 4902 + gap: 0.85rem; 4903 + margin-top: 1.5rem; 4904 + } 4905 + .profile-reviews-summary, 4906 + .profile-reviews-panel, 4907 + .profile-review-card { 4908 + padding: 1.1rem 1.2rem; 4909 + border-radius: 1.2rem; 4910 + } 4911 + .profile-reviews-summary { 4912 + display: grid; 4913 + grid-template-columns: minmax(0, 1fr) minmax(180px, 280px); 4914 + gap: 1.2rem; 4915 + align-items: center; 4916 + } 4917 + .profile-reviews-eyebrow { 4918 + margin: 0; 4919 + font-size: 0.78rem; 4920 + font-weight: 800; 4921 + letter-spacing: 0.08em; 4922 + text-transform: uppercase; 4923 + color: rgba(18, 26, 47, 0.58); 4924 + } 4925 + .profile-reviews-average { 4926 + margin: 0.2rem 0 0; 4927 + font-size: clamp(1.3rem, 2.5vw, 1.9rem); 4928 + font-weight: 850; 4929 + } 4930 + .profile-reviews-average span, 4931 + .profile-review-stars { 4932 + color: #d89a23; 4933 + } 4934 + .profile-reviews-threshold { 4935 + margin: 0.25rem 0 0; 4936 + color: rgba(18, 26, 47, 0.68); 4937 + } 4938 + .profile-rating-distribution { 4939 + display: flex; 4940 + flex-direction: column; 4941 + gap: 0.35rem; 4942 + } 4943 + .profile-rating-row { 4944 + display: grid; 4945 + grid-template-columns: 2.4rem 1fr 2rem; 4946 + gap: 0.5rem; 4947 + align-items: center; 4948 + font-size: 0.8rem; 4949 + color: rgba(18, 26, 47, 0.68); 4950 + } 4951 + .profile-rating-bar { 4952 + height: 0.45rem; 4953 + overflow: hidden; 4954 + border-radius: 999px; 4955 + background: rgba(18, 26, 47, 0.1); 4956 + } 4957 + .profile-rating-bar span { 4958 + display: block; 4959 + height: 100%; 4960 + border-radius: inherit; 4961 + background: linear-gradient(90deg, #d89a23, #f2c75c); 4962 + } 4963 + .profile-reviews-heading { 4964 + margin: 0; 4965 + font-size: 1rem; 4966 + } 4967 + .profile-reviews-panel-header { 4968 + display: flex; 4969 + flex-wrap: wrap; 4970 + align-items: center; 4971 + justify-content: space-between; 4972 + gap: 0.75rem; 4973 + margin-bottom: 0.85rem; 4974 + } 4975 + .profile-review-action-row { 4976 + display: flex; 4977 + flex-wrap: wrap; 4978 + justify-content: flex-end; 4979 + align-items: center; 4980 + gap: 0.75rem; 4981 + } 4982 + .profile-review-action-hint, 4983 + .profile-review-owner-note { 4984 + margin: 0; 4985 + font-size: 0.88rem; 4986 + color: rgba(18, 26, 47, 0.62); 4987 + } 4988 + .profile-review-rating-field { 4989 + display: flex; 4990 + gap: 0.2rem; 4991 + margin: 0 0 0.85rem; 4992 + padding: 0; 4993 + border: 0; 4994 + } 4995 + .profile-review-rating-field legend, 4996 + .profile-review-body-field span { 4997 + display: block; 4998 + margin-bottom: 0.4rem; 4999 + font-size: 0.82rem; 5000 + font-weight: 750; 5001 + color: rgba(18, 26, 47, 0.7); 5002 + } 5003 + .profile-review-star { 5004 + background: none; 5005 + border: 0; 5006 + color: rgba(18, 26, 47, 0.22); 5007 + cursor: pointer; 5008 + font-size: 1.8rem; 5009 + line-height: 1; 5010 + padding: 0 0.1rem; 5011 + } 5012 + .profile-review-star.is-active { 5013 + color: #d89a23; 5014 + } 5015 + .profile-review-body-field textarea, 5016 + .profile-review-response-composer textarea { 5017 + width: 100%; 5018 + min-height: 5.5rem; 5019 + resize: vertical; 5020 + border: 1px solid rgba(18, 26, 47, 0.14); 5021 + border-radius: 0.9rem; 5022 + padding: 0.8rem; 5023 + background: rgba(255, 255, 255, 0.92); 5024 + color: inherit; 5025 + font: inherit; 5026 + } 5027 + .profile-review-char-count { 5028 + margin: 0.35rem 0 0; 5029 + text-align: right; 5030 + font-size: 0.78rem; 5031 + color: rgba(18, 26, 47, 0.55); 5032 + } 5033 + .profile-review-composer-actions { 5034 + display: flex; 5035 + flex-wrap: wrap; 5036 + gap: 0.65rem; 5037 + justify-content: flex-end; 5038 + margin-top: 0.85rem; 5039 + } 5040 + .profile-review-cards { 5041 + display: flex; 5042 + flex-direction: column; 5043 + gap: 0.85rem; 5044 + } 5045 + .profile-reviews-empty { 5046 + margin-top: 0.25rem; 5047 + } 5048 + .profile-review-header { 5049 + display: flex; 5050 + justify-content: space-between; 5051 + gap: 1rem; 5052 + align-items: flex-start; 5053 + } 5054 + .profile-review-author, 5055 + .profile-review-date, 5056 + .profile-review-body, 5057 + .profile-review-stars { 5058 + margin: 0; 5059 + } 5060 + .profile-review-author { 5061 + font-weight: 800; 5062 + } 5063 + .profile-review-date { 5064 + margin-top: 0.15rem; 5065 + font-size: 0.78rem; 5066 + color: rgba(18, 26, 47, 0.55); 5067 + } 5068 + .profile-review-stars { 5069 + flex: 0 0 auto; 5070 + letter-spacing: 0.05em; 5071 + } 5072 + .profile-review-stars span { 5073 + color: rgba(18, 26, 47, 0.24); 5074 + } 5075 + .profile-review-body { 5076 + margin-top: 0.8rem; 5077 + white-space: pre-wrap; 5078 + } 5079 + .profile-review-response { 5080 + margin-top: 0.9rem; 5081 + border-left: 3px solid rgba(18, 26, 47, 0.18); 5082 + padding: 0.1rem 0 0.1rem 0.85rem; 5083 + } 5084 + .profile-review-response p { 5085 + margin: 0.2rem 0 0; 5086 + } 5087 + .profile-review-response-label { 5088 + font-size: 0.78rem; 5089 + font-weight: 850; 5090 + letter-spacing: 0.04em; 5091 + text-transform: uppercase; 5092 + color: rgba(18, 26, 47, 0.58); 5093 + } 5094 + .profile-review-response-toggle, 5095 + .profile-review-report-button { 5096 + margin-top: 0.9rem; 5097 + } 5098 + .profile-review-report-button { 5099 + display: inline-flex; 5100 + } 5101 + .profile-review-response-composer { 5102 + margin-top: 0.9rem; 5103 + } 5104 + .dark-phase .profile-reviews-eyebrow, 5105 + .dark-phase .profile-review-rating-field legend, 5106 + .dark-phase .profile-review-body-field span, 5107 + .dark-phase .profile-review-response-label { 5108 + color: rgba(255, 255, 255, 0.65); 5109 + } 5110 + .dark-phase .profile-reviews-threshold, 5111 + .dark-phase .profile-rating-row, 5112 + .dark-phase .profile-review-date, 5113 + .dark-phase .profile-review-char-count, 5114 + .dark-phase .profile-review-action-hint, 5115 + .dark-phase .profile-review-owner-note { 5116 + color: rgba(255, 255, 255, 0.58); 5117 + } 5118 + .dark-phase .profile-rating-bar { 5119 + background: rgba(255, 255, 255, 0.12); 5120 + } 5121 + .dark-phase .profile-review-star { 5122 + color: rgba(255, 255, 255, 0.22); 5123 + } 5124 + .dark-phase .profile-review-star.is-active { 5125 + color: #f2c75c; 5126 + } 5127 + .dark-phase .profile-review-stars span { 5128 + color: rgba(255, 255, 255, 0.25); 5129 + } 5130 + .dark-phase .profile-review-body-field textarea, 5131 + .dark-phase .profile-review-response-composer textarea { 5132 + background: rgba(255, 255, 255, 0.06); 5133 + border-color: rgba(255, 255, 255, 0.12); 5134 + } 5135 + .dark-phase .profile-review-response { 5136 + border-left-color: rgba(255, 255, 255, 0.2); 5137 + } 5138 + @media (max-width: 640px) { 5139 + .profile-reviews-summary { 5140 + grid-template-columns: 1fr; 5141 + } 5142 + .profile-review-header { 5143 + flex-direction: column; 5144 + } 4685 5145 } 4686 5146 4687 5147 /* Modal-internal styles for the report dialog */
+2 -2
components/explore/CategoryTabs.tsx
··· 1 - import { CATEGORIES, type Category } from "../../lib/lexicons.ts"; 1 + import { type Category, PUBLIC_CATEGORIES } from "../../lib/lexicons.ts"; 2 2 import { useT } from "../../i18n/mod.ts"; 3 3 4 4 interface Props { ··· 24 24 > 25 25 {t.categories.all} 26 26 </a> 27 - {CATEGORIES.map((c: Category) => ( 27 + {PUBLIC_CATEGORIES.map((c: Category) => ( 28 28 <a 29 29 key={c} 30 30 href={buildHref(c, query)}
+33 -16
components/explore/ProfileCard.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 + import { PUBLIC_CATEGORIES } from "../../lib/lexicons.ts"; 2 3 import { useT } from "../../i18n/mod.ts"; 3 4 import VerifiedBadge from "../VerifiedBadge.tsx"; 4 5 ··· 15 16 export default function ProfileCard({ profile }: Props) { 16 17 const t = useT(); 17 18 const tCat = t.categories as Record<string, string>; 18 - const cats = profile.categories.slice(0, 3); 19 + const publicCategories = profile.categories.filter((c) => 20 + (PUBLIC_CATEGORIES as readonly string[]).includes(c) 21 + ); 22 + const appSubcategories = publicCategories.includes("app") 23 + ? profile.subcategories.slice(0, 2) 24 + : []; 19 25 const featured = profile.featured; 20 26 21 27 return ( ··· 62 68 {profile.description && ( 63 69 <p class="profile-card-description">{profile.description}</p> 64 70 )} 65 - <p class="profile-card-meta"> 66 - {cats.map((c) => ( 67 - <span key={c} class="profile-card-category"> 68 - {tCat[c] ?? c} 69 - </span> 70 - ))} 71 - {profile.subcategories.slice(0, 2).map((s) => { 72 - const sub = (t.subcategories as Record<string, string>)[s] ?? s; 73 - return ( 74 - <span key={s} class="profile-card-sub"> 75 - {sub} 76 - </span> 77 - ); 78 - })} 79 - </p> 71 + {(publicCategories.length > 0 || appSubcategories.length > 0) && ( 72 + <div class="profile-card-meta"> 73 + {publicCategories.length > 0 && ( 74 + <div class="profile-card-categories"> 75 + {publicCategories.map((c) => ( 76 + <span key={c} class="profile-card-category"> 77 + {tCat[c] ?? c} 78 + </span> 79 + ))} 80 + </div> 81 + )} 82 + {appSubcategories.length > 0 && ( 83 + <div class="profile-card-subcategories"> 84 + {appSubcategories.map((s) => { 85 + const sub = (t.subcategories as Record<string, string>)[s] ?? 86 + s; 87 + return ( 88 + <span key={s} class="profile-card-sub"> 89 + {sub} 90 + </span> 91 + ); 92 + })} 93 + </div> 94 + )} 95 + </div> 96 + )} 80 97 </div> 81 98 </a> 82 99 );
+29 -13
components/explore/ProfileHero.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 + import { PUBLIC_CATEGORIES } from "../../lib/lexicons.ts"; 2 3 import { useT } from "../../i18n/mod.ts"; 3 4 import VerifiedBadge from "../VerifiedBadge.tsx"; 4 5 import WebsiteIcon from "../icons/WebsiteIcon.tsx"; ··· 19 20 const tBadges = t.badges; 20 21 const tLink = t.linkKinds; 21 22 const featured = profile.featured; 22 - const cats = profile.categories; 23 + const publicCategories = profile.categories.filter((c) => 24 + (PUBLIC_CATEGORIES as readonly string[]).includes(c) 25 + ); 26 + const appSubcategories = publicCategories.includes("app") 27 + ? profile.subcategories 28 + : []; 23 29 const primaryLinks = [ 24 30 profile.mainLink 25 31 ? { ··· 81 87 )} 82 88 </div> 83 89 <p class="profile-hero-handle">@{profile.handle}</p> 84 - <div class="profile-hero-meta"> 85 - {cats.map((c) => ( 86 - <span key={c} class="profile-card-category"> 87 - {tCat[c] ?? c} 88 - </span> 89 - ))} 90 - {profile.subcategories.map((s) => ( 91 - <span key={s} class="profile-card-sub"> 92 - {tSub[s] ?? s} 93 - </span> 94 - ))} 95 - </div> 90 + {(publicCategories.length > 0 || appSubcategories.length > 0) && ( 91 + <div class="profile-hero-meta"> 92 + {publicCategories.length > 0 && ( 93 + <div class="profile-card-categories"> 94 + {publicCategories.map((c) => ( 95 + <span key={c} class="profile-card-category"> 96 + {tCat[c] ?? c} 97 + </span> 98 + ))} 99 + </div> 100 + )} 101 + {appSubcategories.length > 0 && ( 102 + <div class="profile-card-subcategories"> 103 + {appSubcategories.map((s) => ( 104 + <span key={s} class="profile-card-sub"> 105 + {tSub[s] ?? s} 106 + </span> 107 + ))} 108 + </div> 109 + )} 110 + </div> 111 + )} 96 112 {profile.description && ( 97 113 <p class="profile-hero-description">{profile.description}</p> 98 114 )}
+64
components/explore/ProfileRatingSummary.tsx
··· 1 + import { 2 + REVIEW_AGGREGATE_MIN_COUNT, 3 + type ReviewSummary, 4 + } from "../../lib/reviews.ts"; 5 + 6 + interface Props { 7 + summary: ReviewSummary; 8 + copy: { 9 + heading: string; 10 + threshold: (count: number, needed: number) => string; 11 + average: (rating: string, count: number) => string; 12 + distributionLabel: (stars: number, count: number) => string; 13 + }; 14 + } 15 + 16 + export default function ProfileRatingSummary({ summary, copy }: Props) { 17 + const hasAggregate = summary.averageRating != null && summary.distribution; 18 + return ( 19 + <section class="profile-reviews-summary glass"> 20 + <div> 21 + <p class="profile-reviews-eyebrow">{copy.heading}</p> 22 + {hasAggregate 23 + ? ( 24 + <p class="profile-reviews-average"> 25 + <span aria-hidden="true">★</span> {copy.average( 26 + summary.averageRating!.toFixed(1), 27 + summary.visibleCount, 28 + )} 29 + </p> 30 + ) 31 + : ( 32 + <p class="profile-reviews-threshold"> 33 + {copy.threshold( 34 + summary.visibleCount, 35 + Math.max(0, REVIEW_AGGREGATE_MIN_COUNT - summary.visibleCount), 36 + )} 37 + </p> 38 + )} 39 + </div> 40 + {hasAggregate && ( 41 + <div class="profile-rating-distribution"> 42 + {[5, 4, 3, 2, 1].map((stars) => { 43 + const count = summary.distribution![stars as 1 | 2 | 3 | 4 | 5]; 44 + const pct = summary.visibleCount > 0 45 + ? Math.round((count / summary.visibleCount) * 100) 46 + : 0; 47 + return ( 48 + <div class="profile-rating-row" key={stars}> 49 + <span>{stars}★</span> 50 + <div 51 + class="profile-rating-bar" 52 + aria-label={copy.distributionLabel(stars, count)} 53 + > 54 + <span style={{ width: `${pct}%` }} /> 55 + </div> 56 + <span>{count}</span> 57 + </div> 58 + ); 59 + })} 60 + </div> 61 + )} 62 + </section> 63 + ); 64 + }
+117
components/explore/ProfileReviewList.tsx
··· 1 + import type { ComponentChildren } from "preact"; 2 + import type { ReviewRow } from "../../lib/reviews.ts"; 3 + import ReportReviewButton from "../../islands/ReportReviewButton.tsx"; 4 + import ReviewResponseComposer from "../../islands/ReviewResponseComposer.tsx"; 5 + 6 + export interface DisplayReview extends ReviewRow { 7 + reviewerHandle: string | null; 8 + } 9 + 10 + interface Props { 11 + reviews: DisplayReview[]; 12 + signedIn: boolean; 13 + isOwner: boolean; 14 + action?: ComponentChildren; 15 + copy: { 16 + heading: string; 17 + empty: string; 18 + reviewerFallback: string; 19 + edited: string; 20 + ownerResponse: string; 21 + report: { 22 + button: string; 23 + modalTitle: string; 24 + modalBody: string; 25 + reasonLabel: string; 26 + detailsLabel: string; 27 + detailsPlaceholder: string; 28 + submit: string; 29 + submitting: string; 30 + cancel: string; 31 + sentTitle: string; 32 + sentBody: string; 33 + signInRequired: string; 34 + error: string; 35 + reasons: Record<"harmful" | "spam" | "off_topic" | "other", string>; 36 + }; 37 + response: { 38 + button: string; 39 + updateButton: string; 40 + deleteButton: string; 41 + placeholder: string; 42 + submit: string; 43 + submitting: string; 44 + cancel: string; 45 + error: string; 46 + }; 47 + }; 48 + } 49 + 50 + export default function ProfileReviewList( 51 + { reviews, signedIn, isOwner, action, copy }: Props, 52 + ) { 53 + return ( 54 + <section class="profile-reviews-panel glass"> 55 + <div class="profile-reviews-panel-header"> 56 + <h2 class="profile-reviews-heading">{copy.heading}</h2> 57 + {action} 58 + </div> 59 + {reviews.length === 0 60 + ? <p class="text-body profile-reviews-empty">{copy.empty}</p> 61 + : ( 62 + <div class="profile-review-cards"> 63 + {reviews.map((review) => ( 64 + <article class="profile-review-card glass" key={review.id}> 65 + <header class="profile-review-header"> 66 + <div> 67 + <p class="profile-review-author"> 68 + {review.reviewerHandle 69 + ? `@${review.reviewerHandle}` 70 + : copy.reviewerFallback} 71 + </p> 72 + <p class="profile-review-date"> 73 + {new Date(review.createdAt).toISOString().slice(0, 10)} 74 + {review.updatedAt > review.createdAt && ( 75 + <span>· {copy.edited}</span> 76 + )} 77 + </p> 78 + </div> 79 + <p 80 + class="profile-review-stars" 81 + aria-label={`${review.rating} stars`} 82 + > 83 + {"★".repeat(review.rating)} 84 + <span aria-hidden="true"> 85 + {"☆".repeat(5 - review.rating)} 86 + </span> 87 + </p> 88 + </header> 89 + {review.body && <p class="profile-review-body">{review.body} 90 + </p>} 91 + {review.response && ( 92 + <div class="profile-review-response"> 93 + <p class="profile-review-response-label"> 94 + {copy.ownerResponse} 95 + </p> 96 + <p>{review.response.body}</p> 97 + </div> 98 + )} 99 + {isOwner && ( 100 + <ReviewResponseComposer 101 + reviewId={review.id} 102 + initialBody={review.response?.body ?? ""} 103 + copy={copy.response} 104 + /> 105 + )} 106 + <ReportReviewButton 107 + reviewId={review.id} 108 + signedIn={signedIn} 109 + copy={copy.report} 110 + /> 111 + </article> 112 + ))} 113 + </div> 114 + )} 115 + </section> 116 + ); 117 + }
+56
components/explore/ProfileScreenshots.tsx
··· 1 + import type { ProfileRow } from "../../lib/registry.ts"; 2 + 3 + interface Props { 4 + profile: ProfileRow; 5 + } 6 + 7 + export default function ProfileScreenshots({ profile }: Props) { 8 + if (profile.screenshots.length === 0) return null; 9 + 10 + return ( 11 + <section class="profile-screenshots" aria-label="Screenshots"> 12 + <div class="profile-screenshots-shell" data-screenshot-carousel> 13 + <button 14 + type="button" 15 + class="profile-screenshots-arrow profile-screenshots-arrow--prev" 16 + aria-label="Previous screenshot" 17 + data-screenshot-direction="-1" 18 + > 19 + 20 + </button> 21 + <div class="profile-screenshots-carousel"> 22 + {profile.screenshots.map((_, i) => ( 23 + <a 24 + class="profile-screenshot-card" 25 + href={`/api/registry/screenshot/${ 26 + encodeURIComponent(profile.did) 27 + }/${i}`} 28 + target="_blank" 29 + rel="noopener noreferrer" 30 + key={i} 31 + > 32 + <img 33 + src={`/api/registry/screenshot/${ 34 + encodeURIComponent(profile.did) 35 + }/${i}`} 36 + alt={`${profile.name} screenshot ${i + 1}`} 37 + loading="lazy" 38 + decoding="async" 39 + class="profile-screenshot-img" 40 + /> 41 + </a> 42 + ))} 43 + </div> 44 + <button 45 + type="button" 46 + class="profile-screenshots-arrow profile-screenshots-arrow--next" 47 + aria-label="Next screenshot" 48 + data-screenshot-direction="1" 49 + > 50 + 51 + </button> 52 + </div> 53 + <script type="module" src="/profile-screenshot-carousel.js" /> 54 + </section> 55 + ); 56 + }
+127 -2
i18n/messages/en.tsx
··· 543 543 namePlaceholder: "e.g. Bluesky", 544 544 descriptionLabel: "Short description", 545 545 descriptionPlaceholder: "What does it do? Who's it for?", 546 - categoryLabel: "Categories", 546 + categoryLabel: "Category", 547 547 categoryHint: 548 - "Pick all that apply. A project can be both an app and an account provider.", 548 + "Choose App, Account Provider, or both. Selected categories are shown as primary badges.", 549 549 subcategoriesLabel: "Subcategories (optional)", 550 550 subcategoriesHint: "For apps. Pick up to a few.", 551 551 avatarLabel: "Project icon", ··· 607 607 androidHint: "Add this if your project has an Android app.", 608 608 androidInvalid: "Android link must be a valid http(s) URL.", 609 609 }, 610 + screenshots: { 611 + sectionLabel: "Screenshots", 612 + hint: "Optional. Add up to 4 PNG, JPEG, or WebP screenshots, 5MB each.", 613 + upload: "Add screenshots", 614 + addMore: "Add more screenshots", 615 + invalidType: "Screenshots must be PNG, JPEG, or WebP images.", 616 + tooLarge: "Each screenshot must be 5MB or smaller.", 617 + maxReached: "You can add up to 4 screenshots.", 618 + added: (n: number) => 619 + `${n} screenshot${ 620 + n === 1 ? "" : "s" 621 + } ready. Click Update profile to save.`, 622 + partialAdded: (added: number, skipped: number) => 623 + `${added} screenshot${added === 1 ? "" : "s"} ready. ${skipped} file${ 624 + skipped === 1 ? " was" : "s were" 625 + } skipped because screenshots must be PNG, JPEG, or WebP and 5MB or smaller.`, 626 + noneAdded: 627 + "No screenshots were added. Use PNG, JPEG, or WebP images that are 5MB or smaller.", 628 + removeAriaLabel: (n: number) => `Remove screenshot ${n}`, 629 + }, 610 630 customLinks: { 611 631 sectionLabel: "Custom links", 612 632 addButton: "Add custom link", ··· 681 701 "Projects asking to be verified — grants a checkmark on their listing and unlocks SVG icon uploads for the developer API.", 682 702 reportsTitle: "Open reports", 683 703 reportsBody: "User-submitted reports against profiles in Explore.", 704 + reviewReportsTitle: "Review reports", 705 + reviewReportsBody: "Reports against user reviews and ratings.", 684 706 featuredTitle: "Featured", 685 707 featuredBody: 686 708 "Curate the projects that appear in the featured rail at the top of Explore.", ··· 755 777 other: "Other", 756 778 }, 757 779 }, 780 + reviewReports: { 781 + headline: "Review reports", 782 + subhead: 783 + "Reports submitted against user reviews. Hide or remove a review to close the report, or dismiss reports that don't need action.", 784 + empty: "No open review reports.", 785 + action: "Mark actioned", 786 + dismiss: "Dismiss", 787 + hide: "Hide review", 788 + remove: "Remove review", 789 + restore: "Restore review", 790 + actionedLabel: "Actioned", 791 + dismissedLabel: "Dismissed", 792 + hiddenLabel: "Hidden", 793 + removedLabel: "Removed", 794 + restoredLabel: "Restored", 795 + notePlaceholder: "What did you do?", 796 + reasonLabel: "Reason", 797 + reporterLabel: "Reporter", 798 + reviewerLabel: "Reviewer", 799 + detailsLabel: "Details", 800 + reviewLabel: "Review", 801 + submittedAt: "Submitted", 802 + reasons: { 803 + harmful: "Harmful or hateful", 804 + spam: "Spam", 805 + off_topic: "Off-topic", 806 + other: "Other", 807 + }, 808 + }, 758 809 takedowns: { 759 810 headline: "Taken-down profiles", 760 811 subhead: ··· 829 880 impersonation: "Impersonating someone", 830 881 spam: "Spam", 831 882 other: "Other", 883 + }, 884 + }, 885 + 886 + reviews: { 887 + summary: { 888 + heading: "Ratings & Reviews", 889 + threshold: (count: number, needed: number): string => 890 + count === 0 891 + ? "Ratings appear after 5 reviews." 892 + : `${count} review${ 893 + count === 1 ? "" : "s" 894 + } so far. ${needed} more until the average rating appears.`, 895 + average: (rating: string, count: number): string => 896 + `${rating} average from ${count} review${count === 1 ? "" : "s"}`, 897 + distributionLabel: (stars: number, count: number): string => 898 + `${count} ${stars}-star review${count === 1 ? "" : "s"}`, 899 + }, 900 + composer: { 901 + heading: "Write a review", 902 + modalBody: "Rate this project and add a short note for other people.", 903 + signedOut: "Sign in to rate and review this project.", 904 + ownerNote: "You can't review your own project.", 905 + ratingLabel: "Rating", 906 + bodyLabel: "Review (optional)", 907 + bodyPlaceholder: "What should other people know?", 908 + charsRemaining: (n: number): string => `${n} characters remaining`, 909 + charsRemainingSuffix: "characters remaining", 910 + submit: "Post review", 911 + update: "Update review", 912 + submitting: "Saving…", 913 + delete: "Delete review", 914 + signIn: "Sign in", 915 + cancel: "Cancel", 916 + saved: "Review saved.", 917 + deleted: "Review deleted.", 918 + error: "Couldn't save the review. Please try again.", 919 + }, 920 + list: { 921 + heading: "Reviews", 922 + empty: "No reviews yet.", 923 + reviewerFallback: "Atmosphere user", 924 + edited: "edited", 925 + ownerResponse: "Developer response", 926 + }, 927 + report: { 928 + button: "Report review", 929 + modalTitle: "Report this review", 930 + modalBody: "Send a report to the Atmosphere admins.", 931 + reasonLabel: "What's wrong?", 932 + detailsLabel: "Add details (optional)", 933 + detailsPlaceholder: "Anything we should know?", 934 + submit: "Send report", 935 + submitting: "Sending…", 936 + cancel: "Cancel", 937 + sentTitle: "Report sent", 938 + sentBody: "Thanks. An admin will review it shortly.", 939 + signInRequired: "Sign in to report reviews.", 940 + error: "Couldn't send the report. Please try again.", 941 + reasons: { 942 + harmful: "Harmful or hateful content", 943 + spam: "Spam", 944 + off_topic: "Off-topic or not useful", 945 + other: "Other", 946 + }, 947 + }, 948 + response: { 949 + button: "Respond as developer", 950 + updateButton: "Edit developer response", 951 + deleteButton: "Delete response", 952 + placeholder: "Add a short developer response…", 953 + submit: "Save response", 954 + submitting: "Saving…", 955 + cancel: "Cancel", 956 + error: "Couldn't save the response", 832 957 }, 833 958 }, 834 959
+212
islands/AdminReviewReportRow.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + id: number; 5 + reviewId: number; 6 + targetHandle: string; 7 + reviewerDid: string | null; 8 + reporterDid: string | null; 9 + rating: number | null; 10 + body: string | null; 11 + reviewStatus: string | null; 12 + reasonLabel: string; 13 + details: string | null; 14 + createdAt: number; 15 + copy: { 16 + action: string; 17 + dismiss: string; 18 + hide: string; 19 + remove: string; 20 + restore: string; 21 + actionedLabel: string; 22 + dismissedLabel: string; 23 + hiddenLabel: string; 24 + removedLabel: string; 25 + restoredLabel: string; 26 + notePlaceholder: string; 27 + reasonLabel: string; 28 + reporterLabel: string; 29 + reviewerLabel: string; 30 + detailsLabel: string; 31 + reviewLabel: string; 32 + submittedAt: string; 33 + error: string; 34 + }; 35 + } 36 + 37 + type DoneKind = "actioned" | "dismissed" | "hidden" | "removed" | "restored"; 38 + 39 + export default function AdminReviewReportRow(p: Props) { 40 + const notes = useSignal(""); 41 + const status = useSignal< 42 + | { kind: "open" } 43 + | { kind: "submitting" } 44 + | { kind: "done"; action: DoneKind } 45 + | { kind: "error"; text: string } 46 + >({ kind: "open" }); 47 + 48 + const resolve = async (action: "actioned" | "dismissed") => { 49 + status.value = { kind: "submitting" }; 50 + try { 51 + const r = await fetch(`/api/admin/review-reports/${p.id}/resolve`, { 52 + method: "POST", 53 + headers: { "content-type": "application/json" }, 54 + body: JSON.stringify({ 55 + action, 56 + notes: notes.value.trim() || undefined, 57 + }), 58 + }); 59 + if (!r.ok) throw new Error(await r.text()); 60 + status.value = { kind: "done", action }; 61 + } catch (err) { 62 + status.value = { 63 + kind: "error", 64 + text: err instanceof Error ? err.message : String(err), 65 + }; 66 + } 67 + }; 68 + 69 + const moderate = async (action: "hide" | "remove" | "restore") => { 70 + status.value = { kind: "submitting" }; 71 + try { 72 + const r = await fetch(`/api/admin/reviews/${p.reviewId}/${action}`, { 73 + method: "POST", 74 + headers: { "content-type": "application/json" }, 75 + body: JSON.stringify({ notes: notes.value.trim() || undefined }), 76 + }); 77 + if (!r.ok) throw new Error(await r.text()); 78 + if (action !== "restore") { 79 + await resolve("actioned"); 80 + status.value = { 81 + kind: "done", 82 + action: action === "hide" ? "hidden" : "removed", 83 + }; 84 + return; 85 + } 86 + status.value = { kind: "done", action: "restored" }; 87 + } catch (err) { 88 + status.value = { 89 + kind: "error", 90 + text: err instanceof Error ? err.message : String(err), 91 + }; 92 + } 93 + }; 94 + 95 + if (status.value.kind === "done") { 96 + const labels: Record<DoneKind, string> = { 97 + actioned: p.copy.actionedLabel, 98 + dismissed: p.copy.dismissedLabel, 99 + hidden: p.copy.hiddenLabel, 100 + removed: p.copy.removedLabel, 101 + restored: p.copy.restoredLabel, 102 + }; 103 + return ( 104 + <div class="admin-report-row admin-report-row--done"> 105 + <div class="admin-report-meta"> 106 + <span> 107 + <strong>@{p.targetHandle}</strong> 108 + </span> 109 + <span>{p.reasonLabel}</span> 110 + <span> 111 + <span class="admin-status-badge admin-status-badge--approved"> 112 + {labels[status.value.action]} 113 + </span> 114 + </span> 115 + </div> 116 + </div> 117 + ); 118 + } 119 + 120 + const submitted = new Date(p.createdAt).toISOString().slice(0, 10); 121 + const reviewMissing = p.rating == null || p.body == null; 122 + return ( 123 + <div class="admin-report-row"> 124 + <div class="admin-report-meta"> 125 + <span> 126 + <strong> 127 + <a href={`/explore/${p.targetHandle}`}>@{p.targetHandle}</a> 128 + </strong> 129 + </span> 130 + <span> 131 + {p.copy.reasonLabel}: <strong>{p.reasonLabel}</strong> 132 + </span> 133 + <span> 134 + {p.copy.reporterLabel}: <strong>{p.reporterDid ?? "Unknown"}</strong> 135 + </span> 136 + <span> 137 + {p.copy.reviewerLabel}: <strong>{p.reviewerDid ?? "Unknown"}</strong> 138 + </span> 139 + <span> 140 + {p.copy.submittedAt}: <strong>{submitted}</strong> 141 + </span> 142 + </div> 143 + {p.details && ( 144 + <p class="admin-report-details"> 145 + <strong>{p.copy.detailsLabel}:</strong> {p.details} 146 + </p> 147 + )} 148 + <p class="admin-report-details"> 149 + <strong>{p.copy.reviewLabel}:</strong> {reviewMissing 150 + ? "Review no longer exists." 151 + : `${"★".repeat(p.rating!)} ${p.body || "(no text)"}`} 152 + </p> 153 + <div class="admin-report-actions"> 154 + <input 155 + type="text" 156 + class="admin-report-notes-input" 157 + placeholder={p.copy.notePlaceholder} 158 + value={notes.value} 159 + onInput={(e) => 160 + notes.value = (e.currentTarget as HTMLInputElement).value} 161 + /> 162 + <button 163 + type="button" 164 + class="profile-form-button-primary" 165 + onClick={() => resolve("actioned")} 166 + disabled={status.value.kind === "submitting"} 167 + > 168 + {p.copy.action} 169 + </button> 170 + <button 171 + type="button" 172 + class="profile-form-button-secondary" 173 + onClick={() => resolve("dismissed")} 174 + disabled={status.value.kind === "submitting"} 175 + > 176 + {p.copy.dismiss} 177 + </button> 178 + <button 179 + type="button" 180 + class="admin-report-takedown-button" 181 + onClick={() => moderate("hide")} 182 + disabled={status.value.kind === "submitting" || reviewMissing} 183 + > 184 + {p.copy.hide} 185 + </button> 186 + <button 187 + type="button" 188 + class="admin-report-takedown-button" 189 + onClick={() => moderate("remove")} 190 + disabled={status.value.kind === "submitting" || reviewMissing} 191 + > 192 + {p.copy.remove} 193 + </button> 194 + {p.reviewStatus && p.reviewStatus !== "visible" && ( 195 + <button 196 + type="button" 197 + class="profile-form-button-secondary" 198 + onClick={() => moderate("restore")} 199 + disabled={status.value.kind === "submitting" || reviewMissing} 200 + > 201 + {p.copy.restore} 202 + </button> 203 + )} 204 + </div> 205 + {status.value.kind === "error" && ( 206 + <p class="admin-icon-row-error"> 207 + {p.copy.error}: {status.value.text} 208 + </p> 209 + )} 210 + </div> 211 + ); 212 + }
+183 -3
islands/CreateProfileForm.tsx
··· 2 2 import { useSignal } from "@preact/signals"; 3 3 import { 4 4 APP_SUBCATEGORIES, 5 - CATEGORIES, 6 5 type Category, 7 6 type LinkEntry, 7 + PUBLIC_CATEGORIES, 8 8 } from "../lib/lexicons.ts"; 9 9 import { 10 10 type AtmosphereService, ··· 33 33 categories: string[]; 34 34 subcategories: string[]; 35 35 links: LinkEntry[]; 36 + screenshots: Array<{ ref: string; mime: string; size: number }>; 36 37 avatar: { ref: string; mime: string } | null; 37 38 /** Optional developer-facing SVG icon. */ 38 39 icon: ··· 81 82 ref: { $link: string }; 82 83 mimeType: string; 83 84 size: number; 85 + } 86 + 87 + interface ScreenshotDraft { 88 + id: string; 89 + previewUrl: string; 90 + blob: BlobRefShape | null; 91 + file: File | null; 92 + mimeType: string | null; 93 + } 94 + 95 + const SCREENSHOT_MAX_COUNT = 4; 96 + const SCREENSHOT_MAX_BYTES = 5_000_000; 97 + const SCREENSHOT_ACCEPT = ["image/png", "image/jpeg", "image/webp"]; 98 + 99 + function screenshotMimeForFile(file: File): string | null { 100 + if (SCREENSHOT_ACCEPT.includes(file.type)) return file.type; 101 + const name = file.name.toLowerCase(); 102 + if (name.endsWith(".png")) return "image/png"; 103 + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg"; 104 + if (name.endsWith(".webp")) return "image/webp"; 105 + return null; 84 106 } 85 107 86 108 async function readFileAsBase64(file: File): Promise<string> { ··· 206 228 const tCustom = tForm.customLinks; 207 229 const tMainLink = tForm.mainLink; 208 230 const tAppLinks = tForm.appLinks; 231 + const tScreenshots = tForm.screenshots; 209 232 const tManage = t.explore.manage; 210 233 /** Live registry status. Flips on save (-> true) and delete (-> false). */ 211 234 const published = useSignal<boolean>(initialPublished); ··· 229 252 const androidLink = useSignal<string>( 230 253 initial?.androidLink ?? initialSplit.androidLink, 231 254 ); 255 + const initialCategories = initial?.categories?.filter((c) => 256 + (PUBLIC_CATEGORIES as readonly string[]).includes(c) 257 + ); 232 258 const categories = useSignal<string[]>( 233 - initial?.categories?.length ? initial.categories : ["app"], 259 + initialCategories?.length ? initialCategories : ["app"], 234 260 ); 235 261 const subcategories = useSignal<string[]>(initial?.subcategories ?? []); 236 262 ··· 278 304 const avatarFile = useSignal<File | null>(null); 279 305 const avatarRemoved = useSignal(false); 280 306 307 + const screenshots = useSignal<ScreenshotDraft[]>( 308 + (initial?.screenshots ?? []).slice(0, SCREENSHOT_MAX_COUNT).map((s, i) => ({ 309 + id: `existing-${s.ref}-${i}`, 310 + previewUrl: `/api/registry/screenshot/${encodeURIComponent(did)}/${i}`, 311 + blob: { 312 + $type: "blob", 313 + ref: { $link: s.ref }, 314 + mimeType: s.mime, 315 + size: s.size, 316 + }, 317 + file: null, 318 + mimeType: s.mime, 319 + })), 320 + ); 321 + const screenshotMessage = useSignal< 322 + { kind: "ok" | "error"; text: string } | null 323 + >(null); 324 + 281 325 /* ---------------- Developer icon (SVG) signals ----------------------- */ 282 326 /** 283 327 * SVG icons get a separate slot from the main avatar — the avatar is ··· 316 360 317 361 const submitting = useSignal(false); 318 362 const deleting = useSignal(false); 363 + const hydrated = useSignal(false); 319 364 const message = useSignal<{ kind: "ok" | "error"; text: string } | null>( 320 365 null, 321 366 ); 367 + 368 + useEffect(() => { 369 + hydrated.value = true; 370 + }, []); 322 371 323 372 useEffect(() => { 324 373 if (!initial?.avatar) return; ··· 404 453 avatarPreview.value = null; 405 454 }; 406 455 456 + const onScreenshotsChange = (event: Event) => { 457 + const input = event.currentTarget as HTMLInputElement; 458 + const files = Array.from(input.files ?? []); 459 + if (files.length === 0) return; 460 + const available = SCREENSHOT_MAX_COUNT - screenshots.value.length; 461 + if (available <= 0) { 462 + screenshotMessage.value = { 463 + kind: "error", 464 + text: tScreenshots.maxReached, 465 + }; 466 + input.value = ""; 467 + return; 468 + } 469 + const next: ScreenshotDraft[] = []; 470 + let skipped = 0; 471 + for (const file of files.slice(0, available)) { 472 + const mimeType = screenshotMimeForFile(file); 473 + if (!mimeType) { 474 + skipped++; 475 + continue; 476 + } 477 + if (file.size > SCREENSHOT_MAX_BYTES) { 478 + skipped++; 479 + continue; 480 + } 481 + next.push({ 482 + id: `new-${crypto.randomUUID()}`, 483 + previewUrl: URL.createObjectURL(file), 484 + blob: null, 485 + file, 486 + mimeType, 487 + }); 488 + } 489 + skipped += Math.max(0, files.length - available); 490 + if (next.length > 0) { 491 + screenshots.value = [...screenshots.value, ...next]; 492 + screenshotMessage.value = { 493 + kind: skipped > 0 ? "error" : "ok", 494 + text: skipped > 0 495 + ? tScreenshots.partialAdded(next.length, skipped) 496 + : tScreenshots.added(next.length), 497 + }; 498 + } else { 499 + screenshotMessage.value = { 500 + kind: "error", 501 + text: tScreenshots.noneAdded, 502 + }; 503 + } 504 + input.value = ""; 505 + }; 506 + 507 + const removeScreenshot = (id: string) => { 508 + screenshots.value = screenshots.value.filter((s) => s.id !== id); 509 + screenshotMessage.value = null; 510 + }; 511 + 407 512 const onIconChange = (event: Event) => { 408 513 const input = event.currentTarget as HTMLInputElement; 409 514 const file = input.files?.[0]; ··· 577 682 payload.icon = null; 578 683 } 579 684 685 + payload.screenshots = screenshots.value 686 + .filter((s) => s.blob) 687 + .map((s) => ({ image: s.blob })); 688 + payload.screenshotUploads = await Promise.all( 689 + screenshots.value 690 + .filter((s) => s.file) 691 + .map(async (s) => ({ 692 + dataBase64: await readFileAsBase64(s.file as File), 693 + mimeType: s.mimeType ?? (s.file as File).type, 694 + })), 695 + ); 696 + 580 697 const res = await fetch("/api/registry/profile", { 581 698 method: "PUT", 582 699 headers: { "content-type": "application/json" }, ··· 754 871 <fieldset class="profile-form-field"> 755 872 <legend class="profile-form-label">{tForm.categoryLabel}</legend> 756 873 <div class="profile-form-chips" role="group"> 757 - {CATEGORIES.map((c: Category) => { 874 + {PUBLIC_CATEGORIES.map((c: Category) => { 758 875 const selected = categories.value.includes(c); 759 876 return ( 760 877 <label ··· 849 966 </label> 850 967 </div> 851 968 969 + {/* ---------------- Screenshots --------------------------- */} 970 + <div class="profile-form-field profile-screenshots-field"> 971 + <div class="profile-form-section-heading"> 972 + <span class="profile-form-label">{tScreenshots.sectionLabel}</span> 973 + <span class="profile-form-count"> 974 + {screenshots.value.length}/{SCREENSHOT_MAX_COUNT} 975 + </span> 976 + </div> 977 + <p class="profile-form-hint">{tScreenshots.hint}</p> 978 + {screenshotMessage.value && ( 979 + <p 980 + class={`profile-screenshot-status profile-form-status profile-form-status--${screenshotMessage.value.kind}`} 981 + role="status" 982 + > 983 + {screenshotMessage.value.text} 984 + </p> 985 + )} 986 + 987 + {screenshots.value.length > 0 && ( 988 + <div class="profile-screenshot-grid"> 989 + {screenshots.value.map((shot, i) => ( 990 + <div class="profile-screenshot-edit" key={shot.id}> 991 + <img 992 + src={shot.previewUrl} 993 + alt="" 994 + class="profile-screenshot-edit-img" 995 + /> 996 + <button 997 + type="button" 998 + class="profile-screenshot-remove" 999 + aria-label={tScreenshots.removeAriaLabel(i + 1)} 1000 + onClick={() => 1001 + removeScreenshot(shot.id)} 1002 + > 1003 + × 1004 + </button> 1005 + </div> 1006 + ))} 1007 + </div> 1008 + )} 1009 + 1010 + <label class="profile-form-field profile-screenshot-native-picker"> 1011 + <span class="profile-form-label"> 1012 + {screenshots.value.length > 0 1013 + ? tScreenshots.addMore 1014 + : tScreenshots.upload} 1015 + </span> 1016 + <input 1017 + type="file" 1018 + accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp" 1019 + multiple 1020 + disabled={screenshots.value.length >= SCREENSHOT_MAX_COUNT} 1021 + onChange={onScreenshotsChange} 1022 + class="profile-form-input profile-screenshot-file-input" 1023 + /> 1024 + </label> 1025 + </div> 1026 + 852 1027 {/* ---------------- Atmosphere links ----------------------- */} 853 1028 <fieldset class="profile-form-field"> 854 1029 <legend class="profile-form-label">{tAtmos.sectionLabel}</legend> ··· 1095 1270 </span> 1096 1271 )} 1097 1272 </div> 1273 + {!hydrated.value && ( 1274 + <p class="profile-form-hydration-note"> 1275 + Loading editor controls... 1276 + </p> 1277 + )} 1098 1278 1099 1279 {/* ---------------- Verification request modal ---------------- */} 1100 1280 {requestModalOpen.value && (
+211
islands/ProfileReviewComposer.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import type { ReviewRow } from "../lib/reviews.ts"; 3 + 4 + interface Props { 5 + targetId: string; 6 + signedIn: boolean; 7 + isOwner: boolean; 8 + loginHref: string; 9 + ownReview: Pick<ReviewRow, "id" | "rating" | "body"> | null; 10 + copy: { 11 + heading: string; 12 + modalBody: string; 13 + signedOut: string; 14 + ownerNote: string; 15 + ratingLabel: string; 16 + bodyLabel: string; 17 + bodyPlaceholder: string; 18 + charsRemainingSuffix: string; 19 + submit: string; 20 + update: string; 21 + submitting: string; 22 + delete: string; 23 + signIn: string; 24 + cancel: string; 25 + saved: string; 26 + deleted: string; 27 + error: string; 28 + }; 29 + } 30 + 31 + const MAX_BODY = 300; 32 + 33 + export default function ProfileReviewComposer( 34 + { targetId, signedIn, isOwner, loginHref, ownReview, copy }: Props, 35 + ) { 36 + const rating = useSignal<1 | 2 | 3 | 4 | 5>(ownReview?.rating ?? 5); 37 + const body = useSignal(ownReview?.body ?? ""); 38 + const open = useSignal(false); 39 + const submitting = useSignal(false); 40 + const status = useSignal< 41 + | { kind: "idle" } 42 + | { kind: "ok"; text: string } 43 + | { kind: "error"; text: string } 44 + >({ kind: "idle" }); 45 + 46 + const submit = async () => { 47 + submitting.value = true; 48 + status.value = { kind: "idle" }; 49 + try { 50 + const r = await fetch( 51 + `/api/registry/profile/${encodeURIComponent(targetId)}/reviews`, 52 + { 53 + method: "POST", 54 + headers: { "content-type": "application/json" }, 55 + body: JSON.stringify({ 56 + rating: rating.value, 57 + body: body.value.trim(), 58 + }), 59 + }, 60 + ); 61 + if (!r.ok) throw new Error(await r.text()); 62 + status.value = { kind: "ok", text: copy.saved }; 63 + globalThis.location.reload(); 64 + } catch (err) { 65 + status.value = { 66 + kind: "error", 67 + text: err instanceof Error ? err.message : copy.error, 68 + }; 69 + } finally { 70 + submitting.value = false; 71 + } 72 + }; 73 + 74 + const remove = async () => { 75 + submitting.value = true; 76 + status.value = { kind: "idle" }; 77 + try { 78 + const r = await fetch( 79 + `/api/registry/profile/${encodeURIComponent(targetId)}/reviews/me`, 80 + { method: "DELETE" }, 81 + ); 82 + if (!r.ok) throw new Error(await r.text()); 83 + status.value = { kind: "ok", text: copy.deleted }; 84 + globalThis.location.reload(); 85 + } catch (err) { 86 + status.value = { 87 + kind: "error", 88 + text: err instanceof Error ? err.message : copy.error, 89 + }; 90 + } finally { 91 + submitting.value = false; 92 + } 93 + }; 94 + 95 + return ( 96 + <> 97 + <div class="profile-review-action-row"> 98 + {!signedIn 99 + ? ( 100 + <> 101 + <span class="profile-review-action-hint">{copy.signedOut}</span> 102 + <a class="explore-cta-primary" href={loginHref}> 103 + {copy.signIn} 104 + </a> 105 + </> 106 + ) 107 + : isOwner 108 + ? <p class="text-body profile-review-owner-note">{copy.ownerNote}</p> 109 + : ( 110 + <button 111 + type="button" 112 + class="explore-cta-primary" 113 + onClick={() => { 114 + open.value = true; 115 + }} 116 + > 117 + {ownReview ? copy.update : copy.heading} 118 + </button> 119 + )} 120 + </div> 121 + 122 + {open.value && signedIn && !isOwner && ( 123 + <div 124 + class="modal-backdrop" 125 + onClick={(e) => { 126 + if (e.target === e.currentTarget) open.value = false; 127 + }} 128 + > 129 + <div class="modal-card"> 130 + <div class="modal-header"> 131 + <p class="modal-title">{copy.heading}</p> 132 + <p class="modal-body-text">{copy.modalBody}</p> 133 + </div> 134 + <fieldset class="profile-review-rating-field"> 135 + <legend>{copy.ratingLabel}</legend> 136 + {[1, 2, 3, 4, 5].map((n) => ( 137 + <button 138 + type="button" 139 + class={n <= rating.value 140 + ? "profile-review-star is-active" 141 + : "profile-review-star"} 142 + aria-pressed={n <= rating.value} 143 + onClick={() => rating.value = n as 1 | 2 | 3 | 4 | 5} 144 + key={n} 145 + > 146 + 147 + </button> 148 + ))} 149 + </fieldset> 150 + <label class="profile-review-body-field"> 151 + <span>{copy.bodyLabel}</span> 152 + <textarea 153 + maxLength={MAX_BODY} 154 + value={body.value} 155 + placeholder={copy.bodyPlaceholder} 156 + onInput={(e) => 157 + body.value = (e.currentTarget as HTMLTextAreaElement).value} 158 + /> 159 + </label> 160 + <p class="profile-review-char-count"> 161 + {MAX_BODY - body.value.length} {copy.charsRemainingSuffix} 162 + </p> 163 + <div class="profile-review-composer-actions"> 164 + <button 165 + type="button" 166 + class="profile-form-button-link" 167 + onClick={() => { 168 + open.value = false; 169 + }} 170 + disabled={submitting.value} 171 + > 172 + {copy.cancel} 173 + </button> 174 + {ownReview && ( 175 + <button 176 + type="button" 177 + class="profile-form-button-danger" 178 + onClick={remove} 179 + disabled={submitting.value} 180 + > 181 + {copy.delete} 182 + </button> 183 + )} 184 + <button 185 + type="button" 186 + class="profile-form-button-primary" 187 + onClick={submit} 188 + disabled={submitting.value} 189 + > 190 + {submitting.value 191 + ? copy.submitting 192 + : ownReview 193 + ? copy.update 194 + : copy.submit} 195 + </button> 196 + </div> 197 + {status.value.kind !== "idle" && ( 198 + <p 199 + class={status.value.kind === "ok" 200 + ? "report-modal-status report-modal-status--ok" 201 + : "report-modal-status report-modal-status--error"} 202 + > 203 + {status.value.text} 204 + </p> 205 + )} 206 + </div> 207 + </div> 208 + )} 209 + </> 210 + ); 211 + }
+193
islands/ReportReviewButton.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + reviewId: number; 5 + signedIn: boolean; 6 + copy: { 7 + button: string; 8 + modalTitle: string; 9 + modalBody: string; 10 + reasonLabel: string; 11 + detailsLabel: string; 12 + detailsPlaceholder: string; 13 + submit: string; 14 + submitting: string; 15 + cancel: string; 16 + sentTitle: string; 17 + sentBody: string; 18 + signInRequired: string; 19 + error: string; 20 + reasons: Record<"harmful" | "spam" | "off_topic" | "other", string>; 21 + }; 22 + } 23 + 24 + const REASONS: Array<keyof Props["copy"]["reasons"]> = [ 25 + "harmful", 26 + "spam", 27 + "off_topic", 28 + "other", 29 + ]; 30 + 31 + export default function ReportReviewButton( 32 + { reviewId, signedIn, copy }: Props, 33 + ) { 34 + const open = useSignal(false); 35 + const reason = useSignal<keyof Props["copy"]["reasons"]>("harmful"); 36 + const details = useSignal(""); 37 + const submitting = useSignal(false); 38 + const status = useSignal< 39 + | { kind: "idle" } 40 + | { kind: "ok" } 41 + | { kind: "error"; text: string } 42 + >({ kind: "idle" }); 43 + 44 + const close = () => { 45 + open.value = false; 46 + reason.value = "harmful"; 47 + details.value = ""; 48 + status.value = { kind: "idle" }; 49 + }; 50 + 51 + const submit = async () => { 52 + if (!signedIn) { 53 + status.value = { kind: "error", text: copy.signInRequired }; 54 + return; 55 + } 56 + submitting.value = true; 57 + try { 58 + const r = await fetch( 59 + `/api/registry/reviews/${encodeURIComponent(String(reviewId))}/report`, 60 + { 61 + method: "POST", 62 + headers: { "content-type": "application/json" }, 63 + body: JSON.stringify({ 64 + reason: reason.value, 65 + details: details.value.trim() || undefined, 66 + }), 67 + }, 68 + ); 69 + if (!r.ok) throw new Error(await r.text()); 70 + status.value = { kind: "ok" }; 71 + } catch (err) { 72 + status.value = { 73 + kind: "error", 74 + text: err instanceof Error ? err.message : copy.error, 75 + }; 76 + } finally { 77 + submitting.value = false; 78 + } 79 + }; 80 + 81 + return ( 82 + <> 83 + <button 84 + type="button" 85 + class="profile-report-button profile-review-report-button" 86 + onClick={() => open.value = true} 87 + > 88 + {copy.button} 89 + </button> 90 + {open.value && ( 91 + <div 92 + class="modal-backdrop" 93 + onClick={(e) => { 94 + if (e.target === e.currentTarget) close(); 95 + }} 96 + > 97 + <div class="modal-card"> 98 + <div class="modal-header"> 99 + <p class="modal-title">{copy.modalTitle}</p> 100 + <p class="modal-body-text"> 101 + {signedIn ? copy.modalBody : copy.signInRequired} 102 + </p> 103 + </div> 104 + {status.value.kind === "ok" 105 + ? ( 106 + <> 107 + <p class="report-modal-status report-modal-status--ok"> 108 + <strong>{copy.sentTitle}</strong> 109 + </p> 110 + <p class="modal-body-text">{copy.sentBody}</p> 111 + <div 112 + class="report-modal-actions" 113 + style={{ marginTop: "1rem" }} 114 + > 115 + <button 116 + type="button" 117 + class="profile-form-button-primary" 118 + onClick={close} 119 + > 120 + {copy.cancel} 121 + </button> 122 + </div> 123 + </> 124 + ) 125 + : ( 126 + <> 127 + <fieldset class="report-modal-fieldset"> 128 + <legend>{copy.reasonLabel}</legend> 129 + {REASONS.map((r) => ( 130 + <label key={r} class="report-modal-radio"> 131 + <input 132 + type="radio" 133 + name={`review-report-reason-${reviewId}`} 134 + value={r} 135 + checked={reason.value === r} 136 + onChange={() => 137 + reason.value = r} 138 + /> 139 + {copy.reasons[r]} 140 + </label> 141 + ))} 142 + </fieldset> 143 + <label 144 + class="report-modal-radio" 145 + style={{ display: "block" }} 146 + > 147 + <span style={{ display: "block", marginBottom: "0.4rem" }}> 148 + {copy.detailsLabel} 149 + </span> 150 + <textarea 151 + class="report-modal-textarea" 152 + maxLength={500} 153 + placeholder={copy.detailsPlaceholder} 154 + value={details.value} 155 + onInput={(e) => 156 + details.value = 157 + (e.currentTarget as HTMLTextAreaElement).value} 158 + /> 159 + </label> 160 + {status.value.kind === "error" && ( 161 + <p class="report-modal-status report-modal-status--error"> 162 + {copy.error}: {status.value.text} 163 + </p> 164 + )} 165 + <div 166 + class="report-modal-actions" 167 + style={{ marginTop: "1rem" }} 168 + > 169 + <button 170 + type="button" 171 + class="profile-form-button-link" 172 + onClick={close} 173 + disabled={submitting.value} 174 + > 175 + {copy.cancel} 176 + </button> 177 + <button 178 + type="button" 179 + class="profile-form-button-primary" 180 + onClick={submit} 181 + disabled={submitting.value || !signedIn} 182 + > 183 + {submitting.value ? copy.submitting : copy.submit} 184 + </button> 185 + </div> 186 + </> 187 + )} 188 + </div> 189 + </div> 190 + )} 191 + </> 192 + ); 193 + }
+126
islands/ReviewResponseComposer.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + reviewId: number; 5 + initialBody: string; 6 + copy: { 7 + button: string; 8 + updateButton: string; 9 + deleteButton: string; 10 + placeholder: string; 11 + submit: string; 12 + submitting: string; 13 + cancel: string; 14 + error: string; 15 + }; 16 + } 17 + 18 + const MAX_RESPONSE = 500; 19 + 20 + export default function ReviewResponseComposer( 21 + { reviewId, initialBody, copy }: Props, 22 + ) { 23 + const open = useSignal(false); 24 + const body = useSignal(initialBody); 25 + const submitting = useSignal(false); 26 + const error = useSignal<string | null>(null); 27 + 28 + const save = async () => { 29 + submitting.value = true; 30 + error.value = null; 31 + try { 32 + const r = await fetch( 33 + `/api/registry/reviews/${ 34 + encodeURIComponent(String(reviewId)) 35 + }/response`, 36 + { 37 + method: "PUT", 38 + headers: { "content-type": "application/json" }, 39 + body: JSON.stringify({ body: body.value.trim() }), 40 + }, 41 + ); 42 + if (!r.ok) throw new Error(await r.text()); 43 + globalThis.location.reload(); 44 + } catch (err) { 45 + error.value = err instanceof Error ? err.message : copy.error; 46 + } finally { 47 + submitting.value = false; 48 + } 49 + }; 50 + 51 + const remove = async () => { 52 + submitting.value = true; 53 + error.value = null; 54 + try { 55 + const r = await fetch( 56 + `/api/registry/reviews/${ 57 + encodeURIComponent(String(reviewId)) 58 + }/response`, 59 + { method: "DELETE" }, 60 + ); 61 + if (!r.ok) throw new Error(await r.text()); 62 + globalThis.location.reload(); 63 + } catch (err) { 64 + error.value = err instanceof Error ? err.message : copy.error; 65 + } finally { 66 + submitting.value = false; 67 + } 68 + }; 69 + 70 + if (!open.value) { 71 + return ( 72 + <button 73 + type="button" 74 + class="profile-form-button-secondary profile-review-response-toggle" 75 + onClick={() => open.value = true} 76 + > 77 + {initialBody ? copy.updateButton : copy.button} 78 + </button> 79 + ); 80 + } 81 + 82 + return ( 83 + <div class="profile-review-response-composer"> 84 + <textarea 85 + maxLength={MAX_RESPONSE} 86 + placeholder={copy.placeholder} 87 + value={body.value} 88 + onInput={(e) => 89 + body.value = (e.currentTarget as HTMLTextAreaElement).value} 90 + /> 91 + <div class="profile-review-composer-actions"> 92 + <button 93 + type="button" 94 + class="profile-form-button-link" 95 + onClick={() => open.value = false} 96 + disabled={submitting.value} 97 + > 98 + {copy.cancel} 99 + </button> 100 + {initialBody && ( 101 + <button 102 + type="button" 103 + class="profile-form-button-danger" 104 + onClick={remove} 105 + disabled={submitting.value} 106 + > 107 + {copy.deleteButton} 108 + </button> 109 + )} 110 + <button 111 + type="button" 112 + class="profile-form-button-primary" 113 + onClick={save} 114 + disabled={submitting.value || body.value.trim().length === 0} 115 + > 116 + {submitting.value ? copy.submitting : copy.submit} 117 + </button> 118 + </div> 119 + {error.value && ( 120 + <p class="report-modal-status report-modal-status--error"> 121 + {copy.error}: {error.value} 122 + </p> 123 + )} 124 + </div> 125 + ); 126 + }
+21
lexicons/com/atmosphereaccount/registry/profile.json
··· 53 53 "maxSize": 200000, 54 54 "description": "Optional vector icon (SVG) intended for developers building badges, app showcases, sign-in flows, etc. Not displayed on the public Explore profile. Sanitised on upload (script tags, event handlers, foreignObject and javascript:/data: hrefs are stripped)." 55 55 }, 56 + "screenshots": { 57 + "type": "array", 58 + "maxLength": 4, 59 + "items": { 60 + "type": "ref", 61 + "ref": "#screenshotEntry" 62 + }, 63 + "description": "Optional screenshots for the project. Up to 4 PNG, JPEG, or WebP images." 64 + }, 56 65 "categories": { 57 66 "type": "array", 58 67 "minLength": 1, ··· 93 102 "format": "datetime", 94 103 "maxLength": 64 95 104 } 105 + } 106 + } 107 + }, 108 + "screenshotEntry": { 109 + "type": "object", 110 + "required": ["image"], 111 + "properties": { 112 + "image": { 113 + "type": "blob", 114 + "accept": ["image/png", "image/jpeg", "image/webp"], 115 + "maxSize": 5000000, 116 + "description": "Screenshot image. Up to 5MB." 96 117 } 97 118 } 98 119 },
+60
lib/db.ts
··· 98 98 categories TEXT NOT NULL DEFAULT '[]', 99 99 subcategories TEXT NOT NULL DEFAULT '[]', 100 100 links TEXT NOT NULL DEFAULT '[]', 101 + screenshots TEXT NOT NULL DEFAULT '[]', 101 102 avatar_cid TEXT, 102 103 avatar_mime TEXT, 103 104 icon_cid TEXT, ··· 194 195 )`, 195 196 `CREATE INDEX IF NOT EXISTS report_status_target ON report(status, target_did)`, 196 197 `CREATE INDEX IF NOT EXISTS report_dedup ON report(target_did, reporter_ip_hash, reason, created_at)`, 198 + /** 199 + * Signed-in user reviews for registry profiles. Reviews are AppView-owned 200 + * moderation data, not ATProto records: this keeps aggregates and admin 201 + * actions local to the Explore surface. 202 + */ 203 + `CREATE TABLE IF NOT EXISTS review ( 204 + id INTEGER PRIMARY KEY AUTOINCREMENT, 205 + target_did TEXT NOT NULL, 206 + reviewer_did TEXT NOT NULL, 207 + rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5), 208 + body TEXT NOT NULL DEFAULT '', 209 + status TEXT NOT NULL DEFAULT 'visible', 210 + created_at INTEGER NOT NULL, 211 + updated_at INTEGER NOT NULL, 212 + hidden_at INTEGER, 213 + hidden_by TEXT, 214 + removed_at INTEGER, 215 + removed_by TEXT, 216 + admin_notes TEXT 217 + )`, 218 + `CREATE UNIQUE INDEX IF NOT EXISTS review_target_reviewer ON review(target_did, reviewer_did)`, 219 + `CREATE INDEX IF NOT EXISTS review_target_status_rating ON review(target_did, status, rating)`, 220 + `CREATE INDEX IF NOT EXISTS review_target_status_created ON review(target_did, status, created_at)`, 221 + /** 222 + * Reports against individual reviews. Kept separate from profile reports 223 + * because moderation targets and action surfaces differ. 224 + */ 225 + `CREATE TABLE IF NOT EXISTS review_report ( 226 + id INTEGER PRIMARY KEY AUTOINCREMENT, 227 + review_id INTEGER NOT NULL, 228 + reporter_did TEXT, 229 + reporter_ip_hash TEXT, 230 + reason TEXT NOT NULL, 231 + details TEXT, 232 + status TEXT NOT NULL DEFAULT 'open', 233 + admin_notes TEXT, 234 + created_at INTEGER NOT NULL, 235 + resolved_at INTEGER, 236 + resolved_by TEXT 237 + )`, 238 + `CREATE INDEX IF NOT EXISTS review_report_status_review ON review_report(status, review_id)`, 239 + `CREATE INDEX IF NOT EXISTS review_report_dedup ON review_report(review_id, reporter_ip_hash, reason, created_at)`, 240 + /** 241 + * Optional developer response for App Store-style owner replies. One 242 + * response per review; hidden/removed parent reviews are not served publicly. 243 + */ 244 + `CREATE TABLE IF NOT EXISTS review_response ( 245 + review_id INTEGER PRIMARY KEY, 246 + responder_did TEXT NOT NULL, 247 + body TEXT NOT NULL, 248 + created_at INTEGER NOT NULL, 249 + updated_at INTEGER NOT NULL 250 + )`, 197 251 ]; 198 252 199 253 /** ··· 256 310 table: "profile", 257 311 column: "links", 258 312 ddl: "ALTER TABLE profile ADD COLUMN links TEXT NOT NULL DEFAULT '[]'", 313 + }, 314 + { 315 + table: "profile", 316 + column: "screenshots", 317 + ddl: 318 + "ALTER TABLE profile ADD COLUMN screenshots TEXT NOT NULL DEFAULT '[]'", 259 319 }, 260 320 { 261 321 table: "profile",
+59 -5
lib/lexicons.ts
··· 31 31 "developerTool", 32 32 ] as const; 33 33 export type Category = typeof CATEGORIES[number]; 34 + export const PUBLIC_CATEGORIES = [ 35 + "app", 36 + "accountProvider", 37 + ] as const satisfies readonly Category[]; 34 38 35 39 export const APP_SUBCATEGORIES = [ 36 40 "microblog", ··· 108 112 ref: { $link: string }; 109 113 mimeType: string; 110 114 size: number; 115 + } 116 + 117 + export interface ScreenshotEntry { 118 + image: BlobRef; 111 119 } 112 120 113 121 export interface ProfileRecord { ··· 130 138 * public profile. Must be `image/svg+xml`; we sanitise on upload. 131 139 */ 132 140 icon?: BlobRef; 141 + /** Optional detail-page screenshots. Stored as PDS blobs and lazy-loaded 142 + * only on the profile detail page. */ 143 + screenshots?: ScreenshotEntry[]; 133 144 /** All categories that apply to the project (1-4). The first item is the 134 145 * primary category used for sort/grouping in lists. */ 135 146 categories: string[]; ··· 184 195 return true; 185 196 } 186 197 198 + function validateScreenshots( 199 + input: unknown, 200 + ): { ok: true; value: ScreenshotEntry[] } | { ok: false; error: string } { 201 + if (input === undefined) return { ok: true, value: [] }; 202 + if (!Array.isArray(input)) { 203 + return { ok: false, error: "screenshots: must be an array" }; 204 + } 205 + if (input.length > 4) { 206 + return { ok: false, error: "screenshots: at most 4" }; 207 + } 208 + const out: ScreenshotEntry[] = []; 209 + const seen = new Set<string>(); 210 + for (const raw of input) { 211 + if (!raw || typeof raw !== "object") { 212 + return { ok: false, error: "screenshots: items must be objects" }; 213 + } 214 + const image = (raw as Record<string, unknown>).image; 215 + if (!isBlob(image)) { 216 + return { ok: false, error: "screenshots[].image: invalid blob ref" }; 217 + } 218 + if ( 219 + image.mimeType !== "image/png" && 220 + image.mimeType !== "image/jpeg" && 221 + image.mimeType !== "image/webp" 222 + ) { 223 + return { 224 + ok: false, 225 + error: "screenshots[].image: must be png, jpeg, or webp", 226 + }; 227 + } 228 + if (image.size > 5_000_000) { 229 + return { ok: false, error: "screenshots[].image: max 5MB" }; 230 + } 231 + if (seen.has(image.ref.$link)) continue; 232 + seen.add(image.ref.$link); 233 + out.push({ image }); 234 + } 235 + return { ok: true, value: out }; 236 + } 237 + 187 238 export interface ValidationResult<T> { 188 239 ok: boolean; 189 240 value?: T; ··· 386 437 return { ok: false, error: "icon: must be image/svg+xml" }; 387 438 } 388 439 } 440 + const screenshotsRes = validateScreenshots(v.screenshots); 441 + if (!screenshotsRes.ok) return { ok: false, error: screenshotsRes.error }; 389 442 const linksRes = normalizeLinks(v.links); 390 443 if (!linksRes.ok) return { ok: false, error: linksRes.error }; 391 444 if (v.subcategories !== undefined) { ··· 413 466 androidLink: normalizedAndroidLink, 414 467 avatar: v.avatar as BlobRef | undefined, 415 468 icon: v.icon as BlobRef | undefined, 469 + screenshots: screenshotsRes.value.length > 0 470 + ? screenshotsRes.value 471 + : undefined, 416 472 categories: normalizedCategories, 417 473 subcategories: v.subcategories as string[] | undefined, 418 474 links: linksRes.value.length > 0 ? linksRes.value : undefined, ··· 483 539 [PERMISSION_SET_NSID]: "fullPermissions.json", 484 540 }; 485 541 const filename = fileMap[nsid]; 486 - const url = new URL( 487 - `../lexicons/com/atmosphereaccount/registry/${filename}`, 488 - import.meta.url, 489 - ); 490 542 try { 491 - const text = await Deno.readTextFile(url); 543 + const text = await Deno.readTextFile( 544 + `lexicons/com/atmosphereaccount/registry/${filename}`, 545 + ); 492 546 return JSON.parse(text); 493 547 } catch (_) { 494 548 return null;
+9 -1
lib/public-profile.ts
··· 10 10 * “verified” badge as Explore (`icon_access_status === 'granted'`), without 11 11 * exposing emails, timestamps, or admin DIDs. 12 12 */ 13 - import type { LinkEntry } from "./lexicons.ts"; 13 + import type { LinkEntry, ScreenshotEntry } from "./lexicons.ts"; 14 14 import type { ProfileRow } from "./registry.ts"; 15 15 16 16 export interface PublicProfileJson { ··· 24 24 categories: string[]; 25 25 subcategories: string[]; 26 26 links: LinkEntry[]; 27 + screenshots: ScreenshotEntry[]; 28 + /** Fully-qualified URLs for lazily loaded detail-page screenshots. */ 29 + screenshotUrls: string[]; 27 30 avatarCid: string | null; 28 31 avatarMime: string | null; 29 32 /** Fully-qualified URL for the profile avatar image proxy, or null. */ ··· 60 63 profile.iconAccessStatus === "granted" 61 64 ? `${origin}/api/registry/icon/${encodeURIComponent(profile.did)}` 62 65 : null; 66 + const screenshotUrls = profile.screenshots.map((_, i) => 67 + `${origin}/api/registry/screenshot/${encodeURIComponent(profile.did)}/${i}` 68 + ); 63 69 64 70 const out: PublicProfileJson = { 65 71 did: profile.did, ··· 74 80 // `website` was the former Landing Page button. The current public 75 81 // API exposes the primary web destination via `mainLink` instead. 76 82 links: profile.links.filter((entry) => entry.kind !== "website"), 83 + screenshots: profile.screenshots, 84 + screenshotUrls, 77 85 avatarCid: profile.avatarCid, 78 86 avatarMime: profile.avatarMime, 79 87 avatarUrl,
+36 -3
lib/registry.ts
··· 4 4 */ 5 5 import type { InValue } from "@libsql/client"; 6 6 import { withDb } from "./db.ts"; 7 - import type { FeaturedBadge, LinkEntry } from "./lexicons.ts"; 7 + import type { FeaturedBadge, LinkEntry, ScreenshotEntry } from "./lexicons.ts"; 8 8 9 9 /** 10 10 * Approval state of the developer-facing SVG icon. ··· 62 62 subcategories: string[]; 63 63 /** Outbound links (atmosphere services and custom links) in author-defined order. */ 64 64 links: LinkEntry[]; 65 + screenshots: ScreenshotEntry[]; 65 66 avatarCid: string | null; 66 67 avatarMime: string | null; 67 68 /** Optional developer-facing SVG icon. Not rendered on public profile. ··· 109 110 categories: string; 110 111 subcategories: string; 111 112 links: string | null; 113 + screenshots: string | null; 112 114 avatar_cid: string | null; 113 115 avatar_mime: string | null; 114 116 icon_cid: string | null; ··· 168 170 } 169 171 } 170 172 173 + function safeJsonScreenshots( 174 + text: string | null | undefined, 175 + ): ScreenshotEntry[] { 176 + if (!text) return []; 177 + try { 178 + const v = JSON.parse(text); 179 + if (!Array.isArray(v)) return []; 180 + return v 181 + .filter((x): x is Record<string, unknown> => !!x && typeof x === "object") 182 + .map((x) => x.image) 183 + .filter((image): image is ScreenshotEntry["image"] => 184 + !!image && typeof image === "object" && 185 + (image as Record<string, unknown>).$type === "blob" && 186 + typeof ((image as Record<string, unknown>).ref as 187 + | Record< 188 + string, 189 + unknown 190 + > 191 + | undefined)?.$link === "string" && 192 + typeof (image as Record<string, unknown>).mimeType === "string" 193 + ) 194 + .map((image) => ({ image })); 195 + } catch { 196 + return []; 197 + } 198 + } 199 + 171 200 function normalizeIconStatus(v: string | null): IconStatus | null { 172 201 if (v === "pending" || v === "approved" || v === "rejected") return v; 173 202 return null; ··· 198 227 categories: safeJsonArray(r.categories), 199 228 subcategories: safeJsonArray(r.subcategories), 200 229 links: safeJsonLinks(r.links), 230 + screenshots: safeJsonScreenshots(r.screenshots), 201 231 avatarCid: r.avatar_cid, 202 232 avatarMime: r.avatar_mime, 203 233 iconCid: r.icon_cid, ··· 252 282 categories: string[]; 253 283 subcategories: string[]; 254 284 links?: LinkEntry[] | null; 285 + screenshots?: ScreenshotEntry[] | null; 255 286 avatarCid?: string | null; 256 287 avatarMime?: string | null; 257 288 iconCid?: string | null; ··· 295 326 sql: ` 296 327 INSERT INTO profile ( 297 328 did, handle, name, description, main_link, ios_link, android_link, 298 - categories, subcategories, links, 329 + categories, subcategories, links, screenshots, 299 330 avatar_cid, avatar_mime, icon_cid, icon_mime, icon_status, 300 331 icon_reviewed_by, icon_reviewed_at, icon_rejected_reason, 301 332 icon_access_status, icon_access_email, icon_access_requested_at, ··· 304 335 takedown_status, takedown_reason, takedown_by, takedown_at, 305 336 pds_url, record_cid, record_rev, created_at, indexed_at 306 337 ) VALUES ( 307 - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 338 + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 308 339 NULL, NULL, NULL, 309 340 NULL, NULL, NULL, NULL, NULL, NULL, 310 341 NULL, NULL, NULL, NULL, ··· 320 351 categories=excluded.categories, 321 352 subcategories=excluded.subcategories, 322 353 links=excluded.links, 354 + screenshots=excluded.screenshots, 323 355 avatar_cid=excluded.avatar_cid, 324 356 avatar_mime=excluded.avatar_mime, 325 357 icon_cid=excluded.icon_cid, ··· 397 429 JSON.stringify(cats), 398 430 JSON.stringify(input.subcategories ?? []), 399 431 JSON.stringify(input.links ?? []), 432 + JSON.stringify(input.screenshots ?? []), 400 433 input.avatarCid ?? null, 401 434 input.avatarMime ?? null, 402 435 input.iconCid ?? null,
+576
lib/reviews.ts
··· 1 + /** 2 + * Signed-in reviews for registry profiles. Reviews are local AppView data: 3 + * they power Explore profile ratings and can be hidden/removed by admins 4 + * without changing a user's PDS records. 5 + */ 6 + import { withDb } from "./db.ts"; 7 + 8 + export const MAX_REVIEW_BODY_LENGTH = 300; 9 + export const REVIEW_AGGREGATE_MIN_COUNT = 5; 10 + export const MAX_REVIEW_RESPONSE_LENGTH = 500; 11 + 12 + export const REVIEW_REPORT_REASONS = [ 13 + "harmful", 14 + "spam", 15 + "off_topic", 16 + "other", 17 + ] as const; 18 + export type ReviewReportReason = typeof REVIEW_REPORT_REASONS[number]; 19 + 20 + export type ReviewStatus = "visible" | "hidden" | "removed"; 21 + export type ReviewReportStatus = "open" | "actioned" | "dismissed"; 22 + 23 + export interface RatingDistribution { 24 + 1: number; 25 + 2: number; 26 + 3: number; 27 + 4: number; 28 + 5: number; 29 + } 30 + 31 + export interface ReviewSummary { 32 + visibleCount: number; 33 + averageRating: number | null; 34 + distribution: RatingDistribution | null; 35 + } 36 + 37 + export interface ReviewResponseRow { 38 + reviewId: number; 39 + responderDid: string; 40 + body: string; 41 + createdAt: number; 42 + updatedAt: number; 43 + } 44 + 45 + export interface ReviewRow { 46 + id: number; 47 + targetDid: string; 48 + reviewerDid: string; 49 + rating: 1 | 2 | 3 | 4 | 5; 50 + body: string; 51 + status: ReviewStatus; 52 + createdAt: number; 53 + updatedAt: number; 54 + hiddenAt: number | null; 55 + hiddenBy: string | null; 56 + removedAt: number | null; 57 + removedBy: string | null; 58 + adminNotes: string | null; 59 + response: ReviewResponseRow | null; 60 + } 61 + 62 + export interface ReviewReportRow { 63 + id: number; 64 + reviewId: number; 65 + reporterDid: string | null; 66 + reason: ReviewReportReason; 67 + details: string | null; 68 + status: ReviewReportStatus; 69 + adminNotes: string | null; 70 + createdAt: number; 71 + resolvedAt: number | null; 72 + resolvedBy: string | null; 73 + review: ReviewRow | null; 74 + } 75 + 76 + interface RawReviewRow { 77 + id: number; 78 + target_did: string; 79 + reviewer_did: string; 80 + rating: number; 81 + body: string; 82 + status: string; 83 + created_at: number; 84 + updated_at: number; 85 + hidden_at: number | null; 86 + hidden_by: string | null; 87 + removed_at: number | null; 88 + removed_by: string | null; 89 + admin_notes: string | null; 90 + response_body?: string | null; 91 + response_responder_did?: string | null; 92 + response_created_at?: number | null; 93 + response_updated_at?: number | null; 94 + } 95 + 96 + interface RawReviewReportRow { 97 + id: number; 98 + review_id: number; 99 + reporter_did: string | null; 100 + reason: string; 101 + details: string | null; 102 + status: string; 103 + admin_notes: string | null; 104 + created_at: number; 105 + resolved_at: number | null; 106 + resolved_by: string | null; 107 + } 108 + 109 + function normalizeRating(v: number): 1 | 2 | 3 | 4 | 5 { 110 + if (v === 1 || v === 2 || v === 3 || v === 4 || v === 5) return v; 111 + return 1; 112 + } 113 + 114 + function normalizeReviewStatus(v: string): ReviewStatus { 115 + if (v === "hidden" || v === "removed") return v; 116 + return "visible"; 117 + } 118 + 119 + function normalizeReportReason(v: string): ReviewReportReason { 120 + return (REVIEW_REPORT_REASONS as readonly string[]).includes(v) 121 + ? v as ReviewReportReason 122 + : "other"; 123 + } 124 + 125 + function normalizeReportStatus(v: string): ReviewReportStatus { 126 + if (v === "actioned" || v === "dismissed") return v; 127 + return "open"; 128 + } 129 + 130 + function rowToReview(r: RawReviewRow): ReviewRow { 131 + const response = r.response_body != null && r.response_responder_did 132 + ? { 133 + reviewId: Number(r.id), 134 + responderDid: r.response_responder_did, 135 + body: r.response_body, 136 + createdAt: Number(r.response_created_at ?? r.updated_at), 137 + updatedAt: Number(r.response_updated_at ?? r.updated_at), 138 + } 139 + : null; 140 + return { 141 + id: Number(r.id), 142 + targetDid: r.target_did, 143 + reviewerDid: r.reviewer_did, 144 + rating: normalizeRating(Number(r.rating)), 145 + body: r.body ?? "", 146 + status: normalizeReviewStatus(r.status), 147 + createdAt: Number(r.created_at), 148 + updatedAt: Number(r.updated_at), 149 + hiddenAt: r.hidden_at != null ? Number(r.hidden_at) : null, 150 + hiddenBy: r.hidden_by, 151 + removedAt: r.removed_at != null ? Number(r.removed_at) : null, 152 + removedBy: r.removed_by, 153 + adminNotes: r.admin_notes, 154 + response, 155 + }; 156 + } 157 + 158 + function rowToReviewReport( 159 + report: RawReviewReportRow, 160 + review?: RawReviewRow | null, 161 + ): ReviewReportRow { 162 + return { 163 + id: Number(report.id), 164 + reviewId: Number(report.review_id), 165 + reporterDid: report.reporter_did, 166 + reason: normalizeReportReason(report.reason), 167 + details: report.details, 168 + status: normalizeReportStatus(report.status), 169 + adminNotes: report.admin_notes, 170 + createdAt: Number(report.created_at), 171 + resolvedAt: report.resolved_at != null ? Number(report.resolved_at) : null, 172 + resolvedBy: report.resolved_by, 173 + review: review ? rowToReview(review) : null, 174 + }; 175 + } 176 + 177 + export function validateReviewRating(value: unknown): 1 | 2 | 3 | 4 | 5 | null { 178 + return typeof value === "number" && Number.isInteger(value) && 179 + value >= 1 && value <= 5 180 + ? value as 1 | 2 | 3 | 4 | 5 181 + : null; 182 + } 183 + 184 + export function normalizeReviewBody(value: unknown): string | null { 185 + if (typeof value !== "string") return ""; 186 + const body = value.trim(); 187 + if (body.length > MAX_REVIEW_BODY_LENGTH) return null; 188 + return body; 189 + } 190 + 191 + export function normalizeReviewResponseBody(value: unknown): string | null { 192 + if (typeof value !== "string") return null; 193 + const body = value.trim(); 194 + if (!body || body.length > MAX_REVIEW_RESPONSE_LENGTH) return null; 195 + return body; 196 + } 197 + 198 + export async function createOrUpdateReview(input: { 199 + targetDid: string; 200 + reviewerDid: string; 201 + rating: 1 | 2 | 3 | 4 | 5; 202 + body: string; 203 + }): Promise<ReviewRow> { 204 + return await withDb(async (c) => { 205 + const now = Date.now(); 206 + await c.execute({ 207 + sql: ` 208 + INSERT INTO review ( 209 + target_did, reviewer_did, rating, body, status, created_at, updated_at 210 + ) VALUES (?, ?, ?, ?, 'visible', ?, ?) 211 + ON CONFLICT(target_did, reviewer_did) DO UPDATE SET 212 + rating = excluded.rating, 213 + body = excluded.body, 214 + status = 'visible', 215 + updated_at = excluded.updated_at, 216 + hidden_at = NULL, 217 + hidden_by = NULL, 218 + removed_at = NULL, 219 + removed_by = NULL, 220 + admin_notes = NULL 221 + `, 222 + args: [ 223 + input.targetDid, 224 + input.reviewerDid, 225 + input.rating, 226 + input.body, 227 + now, 228 + now, 229 + ], 230 + }); 231 + const review = await getOwnReview(input.targetDid, input.reviewerDid); 232 + if (!review) throw new Error("review_write_failed"); 233 + return review; 234 + }); 235 + } 236 + 237 + export async function getOwnReview( 238 + targetDid: string, 239 + reviewerDid: string, 240 + ): Promise<ReviewRow | null> { 241 + return await withDb(async (c) => { 242 + const r = await c.execute({ 243 + sql: ` 244 + SELECT r.*, rr.body AS response_body, 245 + rr.responder_did AS response_responder_did, 246 + rr.created_at AS response_created_at, 247 + rr.updated_at AS response_updated_at 248 + FROM review r 249 + LEFT JOIN review_response rr ON rr.review_id = r.id 250 + WHERE r.target_did = ? AND r.reviewer_did = ? 251 + LIMIT 1 252 + `, 253 + args: [targetDid, reviewerDid], 254 + }); 255 + const row = r.rows[0] as unknown as RawReviewRow | undefined; 256 + return row ? rowToReview(row) : null; 257 + }); 258 + } 259 + 260 + export async function getReviewById(id: number): Promise<ReviewRow | null> { 261 + return await withDb(async (c) => { 262 + const r = await c.execute({ 263 + sql: ` 264 + SELECT r.*, rr.body AS response_body, 265 + rr.responder_did AS response_responder_did, 266 + rr.created_at AS response_created_at, 267 + rr.updated_at AS response_updated_at 268 + FROM review r 269 + LEFT JOIN review_response rr ON rr.review_id = r.id 270 + WHERE r.id = ? 271 + LIMIT 1 272 + `, 273 + args: [id], 274 + }); 275 + const row = r.rows[0] as unknown as RawReviewRow | undefined; 276 + return row ? rowToReview(row) : null; 277 + }); 278 + } 279 + 280 + export async function deleteOwnReview( 281 + targetDid: string, 282 + reviewerDid: string, 283 + ): Promise<boolean> { 284 + return await withDb(async (c) => { 285 + const r = await c.execute({ 286 + sql: ` 287 + UPDATE review SET 288 + status = 'removed', 289 + updated_at = ?, 290 + removed_at = ?, 291 + removed_by = ? 292 + WHERE target_did = ? AND reviewer_did = ? AND status != 'removed' 293 + `, 294 + args: [Date.now(), Date.now(), reviewerDid, targetDid, reviewerDid], 295 + }); 296 + return Number(r.rowsAffected ?? 0) > 0; 297 + }); 298 + } 299 + 300 + export async function getReviewSummary( 301 + targetDid: string, 302 + ): Promise<ReviewSummary> { 303 + return await withDb(async (c) => { 304 + const r = await c.execute({ 305 + sql: ` 306 + SELECT rating, COUNT(*) AS n 307 + FROM review 308 + WHERE target_did = ? AND status = 'visible' 309 + GROUP BY rating 310 + `, 311 + args: [targetDid], 312 + }); 313 + const distribution: RatingDistribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; 314 + let visibleCount = 0; 315 + let sum = 0; 316 + for ( 317 + const row of r.rows as unknown as Array<{ rating: number; n: number }> 318 + ) { 319 + const rating = normalizeRating(Number(row.rating)); 320 + const n = Number(row.n ?? 0); 321 + distribution[rating] = n; 322 + visibleCount += n; 323 + sum += rating * n; 324 + } 325 + if (visibleCount < REVIEW_AGGREGATE_MIN_COUNT) { 326 + return { visibleCount, averageRating: null, distribution: null }; 327 + } 328 + return { 329 + visibleCount, 330 + averageRating: Math.round((sum / visibleCount) * 10) / 10, 331 + distribution, 332 + }; 333 + }); 334 + } 335 + 336 + export async function listVisibleReviews( 337 + targetDid: string, 338 + opts: { limit?: number; cursor?: number } = {}, 339 + ): Promise<ReviewRow[]> { 340 + const limit = Math.max(1, Math.min(opts.limit ?? 20, 50)); 341 + return await withDb(async (c) => { 342 + const hasCursor = typeof opts.cursor === "number" && 343 + Number.isFinite(opts.cursor); 344 + const r = await c.execute({ 345 + sql: ` 346 + SELECT r.*, rr.body AS response_body, 347 + rr.responder_did AS response_responder_did, 348 + rr.created_at AS response_created_at, 349 + rr.updated_at AS response_updated_at 350 + FROM review r 351 + LEFT JOIN review_response rr ON rr.review_id = r.id 352 + WHERE r.target_did = ? AND r.status = 'visible' 353 + ${hasCursor ? "AND r.created_at < ?" : ""} 354 + ORDER BY r.created_at DESC 355 + LIMIT ? 356 + `, 357 + args: hasCursor ? [targetDid, opts.cursor!, limit] : [targetDid, limit], 358 + }); 359 + return r.rows.map((row) => rowToReview(row as unknown as RawReviewRow)); 360 + }); 361 + } 362 + 363 + export async function upsertReviewResponse(input: { 364 + reviewId: number; 365 + responderDid: string; 366 + body: string; 367 + }): Promise<void> { 368 + await withDb(async (c) => { 369 + const now = Date.now(); 370 + await c.execute({ 371 + sql: ` 372 + INSERT INTO review_response ( 373 + review_id, responder_did, body, created_at, updated_at 374 + ) VALUES (?, ?, ?, ?, ?) 375 + ON CONFLICT(review_id) DO UPDATE SET 376 + responder_did = excluded.responder_did, 377 + body = excluded.body, 378 + updated_at = excluded.updated_at 379 + `, 380 + args: [input.reviewId, input.responderDid, input.body, now, now], 381 + }); 382 + }); 383 + } 384 + 385 + export async function deleteReviewResponse( 386 + reviewId: number, 387 + responderDid: string, 388 + ): Promise<boolean> { 389 + return await withDb(async (c) => { 390 + const r = await c.execute({ 391 + sql: ` 392 + DELETE FROM review_response 393 + WHERE review_id = ? AND responder_did = ? 394 + `, 395 + args: [reviewId, responderDid], 396 + }); 397 + return Number(r.rowsAffected ?? 0) > 0; 398 + }); 399 + } 400 + 401 + const REPORT_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000; 402 + 403 + export async function createReviewReport(input: { 404 + reviewId: number; 405 + reporterDid: string | null; 406 + ipHash: string | null; 407 + reason: ReviewReportReason; 408 + details?: string | null; 409 + }): Promise<{ ok: true; id: number } | { ok: false; reason: "duplicate" }> { 410 + return await withDb(async (c) => { 411 + if (input.ipHash) { 412 + const dup = await c.execute({ 413 + sql: ` 414 + SELECT id FROM review_report 415 + WHERE review_id = ? AND reporter_ip_hash = ? AND reason = ? 416 + AND created_at >= ? 417 + LIMIT 1 418 + `, 419 + args: [ 420 + input.reviewId, 421 + input.ipHash, 422 + input.reason, 423 + Date.now() - REPORT_DEDUP_WINDOW_MS, 424 + ], 425 + }); 426 + if (dup.rows.length > 0) return { ok: false, reason: "duplicate" }; 427 + } 428 + const r = await c.execute({ 429 + sql: ` 430 + INSERT INTO review_report ( 431 + review_id, reporter_did, reporter_ip_hash, reason, details, 432 + status, created_at 433 + ) VALUES (?, ?, ?, ?, ?, 'open', ?) 434 + `, 435 + args: [ 436 + input.reviewId, 437 + input.reporterDid, 438 + input.ipHash, 439 + input.reason, 440 + input.details ?? null, 441 + Date.now(), 442 + ], 443 + }); 444 + return { ok: true, id: Number(r.lastInsertRowid ?? 0) }; 445 + }); 446 + } 447 + 448 + export async function listOpenReviewReports(): Promise<ReviewReportRow[]> { 449 + return await withDb(async (c) => { 450 + const r = await c.execute(` 451 + SELECT 452 + rp.id AS report_id, rp.review_id, rp.reporter_did, rp.reason, 453 + rp.details, rp.status AS report_status, rp.admin_notes AS report_notes, 454 + rp.created_at AS report_created_at, rp.resolved_at, rp.resolved_by, 455 + rv.id, rv.target_did, rv.reviewer_did, rv.rating, rv.body, 456 + rv.status, rv.created_at, rv.updated_at, rv.hidden_at, rv.hidden_by, 457 + rv.removed_at, rv.removed_by, rv.admin_notes 458 + FROM review_report rp 459 + LEFT JOIN review rv ON rv.id = rp.review_id 460 + WHERE rp.status = 'open' 461 + ORDER BY rp.created_at ASC 462 + `); 463 + return r.rows.map((row) => { 464 + const record = row as Record<string, unknown>; 465 + const report: RawReviewReportRow = { 466 + id: Number(record.report_id), 467 + review_id: Number(record.review_id), 468 + reporter_did: record.reporter_did as string | null, 469 + reason: String(record.reason ?? "other"), 470 + details: record.details as string | null, 471 + status: String(record.report_status ?? "open"), 472 + admin_notes: record.report_notes as string | null, 473 + created_at: Number(record.report_created_at), 474 + resolved_at: record.resolved_at == null 475 + ? null 476 + : Number(record.resolved_at), 477 + resolved_by: record.resolved_by as string | null, 478 + }; 479 + const review = record.id == null ? null : { 480 + id: Number(record.id), 481 + target_did: String(record.target_did), 482 + reviewer_did: String(record.reviewer_did), 483 + rating: Number(record.rating), 484 + body: String(record.body ?? ""), 485 + status: String(record.status ?? "visible"), 486 + created_at: Number(record.created_at), 487 + updated_at: Number(record.updated_at), 488 + hidden_at: record.hidden_at == null ? null : Number(record.hidden_at), 489 + hidden_by: record.hidden_by as string | null, 490 + removed_at: record.removed_at == null 491 + ? null 492 + : Number(record.removed_at), 493 + removed_by: record.removed_by as string | null, 494 + admin_notes: record.admin_notes as string | null, 495 + }; 496 + return rowToReviewReport(report, review); 497 + }); 498 + }); 499 + } 500 + 501 + export async function countOpenReviewReports(): Promise<number> { 502 + return await withDb(async (c) => { 503 + const r = await c.execute( 504 + `SELECT COUNT(*) AS n FROM review_report WHERE status = 'open'`, 505 + ); 506 + return Number((r.rows[0] as Record<string, unknown>).n ?? 0); 507 + }); 508 + } 509 + 510 + export async function resolveReviewReport( 511 + id: number, 512 + resolver: string, 513 + status: "actioned" | "dismissed", 514 + notes?: string | null, 515 + ): Promise<void> { 516 + await withDb(async (c) => { 517 + await c.execute({ 518 + sql: ` 519 + UPDATE review_report SET 520 + status = ?, 521 + admin_notes = ?, 522 + resolved_at = ?, 523 + resolved_by = ? 524 + WHERE id = ? 525 + `, 526 + args: [status, notes ?? null, Date.now(), resolver, id], 527 + }); 528 + }); 529 + } 530 + 531 + export async function moderateReview( 532 + id: number, 533 + moderatorDid: string, 534 + action: "hide" | "remove" | "restore", 535 + notes?: string | null, 536 + ): Promise<boolean> { 537 + return await withDb(async (c) => { 538 + const now = Date.now(); 539 + const sql = action === "restore" 540 + ? ` 541 + UPDATE review SET 542 + status = 'visible', 543 + updated_at = ?, 544 + hidden_at = NULL, 545 + hidden_by = NULL, 546 + removed_at = NULL, 547 + removed_by = NULL, 548 + admin_notes = ? 549 + WHERE id = ? 550 + ` 551 + : action === "hide" 552 + ? ` 553 + UPDATE review SET 554 + status = 'hidden', 555 + updated_at = ?, 556 + hidden_at = ?, 557 + hidden_by = ?, 558 + admin_notes = ? 559 + WHERE id = ? 560 + ` 561 + : ` 562 + UPDATE review SET 563 + status = 'removed', 564 + updated_at = ?, 565 + removed_at = ?, 566 + removed_by = ?, 567 + admin_notes = ? 568 + WHERE id = ? 569 + `; 570 + const args = action === "restore" 571 + ? [now, notes ?? null, id] 572 + : [now, now, moderatorDid, notes ?? null, id]; 573 + const r = await c.execute({ sql, args }); 574 + return Number(r.rowsAffected ?? 0) > 0; 575 + }); 576 + }
+25 -7
routes/admin/index.tsx
··· 13 13 countTakenDownProfiles, 14 14 } from "../../lib/registry.ts"; 15 15 import { countOpenReports } from "../../lib/reports.ts"; 16 + import { countOpenReviewReports } from "../../lib/reviews.ts"; 16 17 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 17 18 18 19 export const handler = define.handlers({ 19 20 async GET(ctx) { 20 - const [iconAccessRequests, openReports, takenDown] = await Promise.all([ 21 - countPendingIconAccess().catch(() => 0), 22 - countOpenReports().catch(() => 0), 23 - countTakenDownProfiles().catch(() => 0), 24 - ]); 21 + const [iconAccessRequests, openReports, openReviewReports, takenDown] = 22 + await Promise.all([ 23 + countPendingIconAccess().catch(() => 0), 24 + countOpenReports().catch(() => 0), 25 + countOpenReviewReports().catch(() => 0), 26 + countTakenDownProfiles().catch(() => 0), 27 + ]); 25 28 return ctx.render( 26 29 <AdminHome 27 30 account={buildAccountMenuProps(ctx.state)} 28 31 iconAccessRequests={iconAccessRequests} 29 32 openReports={openReports} 33 + openReviewReports={openReviewReports} 30 34 takenDown={takenDown} 31 35 locale={ctx.state.locale} 32 36 />, ··· 38 42 account: ReturnType<typeof buildAccountMenuProps>; 39 43 iconAccessRequests: number; 40 44 openReports: number; 45 + openReviewReports: number; 41 46 takenDown: number; 42 47 locale: Locale; 43 48 } 44 49 45 50 function AdminHome( 46 - { account, iconAccessRequests, openReports, takenDown, locale }: 47 - AdminHomeProps, 51 + { 52 + account, 53 + iconAccessRequests, 54 + openReports, 55 + openReviewReports, 56 + takenDown, 57 + locale, 58 + }: AdminHomeProps, 48 59 ) { 49 60 const t = getMessages(locale).admin; 50 61 return ( ··· 69 80 <p class="admin-card-count">{openReports}</p> 70 81 <h2 class="admin-card-title">{t.overview.reportsTitle}</h2> 71 82 <p class="admin-card-body">{t.overview.reportsBody}</p> 83 + </a> 84 + <a href="/admin/reviews" class="admin-card"> 85 + <p class="admin-card-count">{openReviewReports}</p> 86 + <h2 class="admin-card-title"> 87 + {t.overview.reviewReportsTitle} 88 + </h2> 89 + <p class="admin-card-body">{t.overview.reviewReportsBody}</p> 72 90 </a> 73 91 <a href="/admin/featured" class="admin-card"> 74 92 <p class="admin-card-count">★</p>
+124
routes/admin/reviews.tsx
··· 1 + /** 2 + * Admin: open review report inbox. 3 + */ 4 + import { define } from "../../utils.ts"; 5 + import Nav from "../../components/Nav.tsx"; 6 + import GlassClouds from "../../components/GlassClouds.tsx"; 7 + import Footer from "../../components/Footer.tsx"; 8 + import AdminReviewReportRow from "../../islands/AdminReviewReportRow.tsx"; 9 + import { getMessages } from "../../i18n/mod.ts"; 10 + import type { Locale } from "../../i18n/mod.ts"; 11 + import { getProfileByDid } from "../../lib/registry.ts"; 12 + import { 13 + listOpenReviewReports, 14 + type ReviewReportRow, 15 + } from "../../lib/reviews.ts"; 16 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 17 + 18 + interface ReviewReportWithHandle extends ReviewReportRow { 19 + targetHandle: string; 20 + } 21 + 22 + export const handler = define.handlers({ 23 + async GET(ctx) { 24 + const reports = await listOpenReviewReports().catch(() => 25 + [] as ReviewReportRow[] 26 + ); 27 + const enriched: ReviewReportWithHandle[] = await Promise.all( 28 + reports.map(async (r) => { 29 + const p = r.review 30 + ? await getProfileByDid(r.review.targetDid, { 31 + includeTakenDown: true, 32 + }) 33 + .catch(() => null) 34 + : null; 35 + return { 36 + ...r, 37 + targetHandle: p?.handle ?? r.review?.targetDid ?? "unknown", 38 + }; 39 + }), 40 + ); 41 + return ctx.render( 42 + <AdminReviewReportsPage 43 + account={buildAccountMenuProps(ctx.state)} 44 + reports={enriched} 45 + locale={ctx.state.locale} 46 + />, 47 + ); 48 + }, 49 + }); 50 + 51 + interface PageProps { 52 + account: ReturnType<typeof buildAccountMenuProps>; 53 + reports: ReviewReportWithHandle[]; 54 + locale: Locale; 55 + } 56 + 57 + function AdminReviewReportsPage({ account, reports, locale }: PageProps) { 58 + const t = getMessages(locale).admin; 59 + return ( 60 + <div id="page-top"> 61 + <GlassClouds /> 62 + <div class="content-layer"> 63 + <Nav account={account} /> 64 + <section class="admin-section"> 65 + <div class="container" style={{ maxWidth: "920px" }}> 66 + <p> 67 + <a href="/admin" class="text-link-button"> 68 + ← {t.backToOverview} 69 + </a> 70 + </p> 71 + <header class="admin-header" style={{ marginTop: "0.75rem" }}> 72 + <h1 class="text-section">{t.reviewReports.headline}</h1> 73 + <p class="text-body mt-2">{t.reviewReports.subhead}</p> 74 + </header> 75 + 76 + {reports.length === 0 77 + ? <p class="text-body admin-empty">{t.reviewReports.empty}</p> 78 + : ( 79 + <div class="admin-report-list"> 80 + {reports.map((r) => ( 81 + <AdminReviewReportRow 82 + key={r.id} 83 + id={r.id} 84 + reviewId={r.reviewId} 85 + targetHandle={r.targetHandle} 86 + reviewerDid={r.review?.reviewerDid ?? null} 87 + reporterDid={r.reporterDid} 88 + rating={r.review?.rating ?? null} 89 + body={r.review?.body ?? null} 90 + reviewStatus={r.review?.status ?? null} 91 + reasonLabel={t.reviewReports.reasons[r.reason]} 92 + details={r.details} 93 + createdAt={r.createdAt} 94 + copy={{ 95 + action: t.reviewReports.action, 96 + dismiss: t.reviewReports.dismiss, 97 + hide: t.reviewReports.hide, 98 + remove: t.reviewReports.remove, 99 + restore: t.reviewReports.restore, 100 + actionedLabel: t.reviewReports.actionedLabel, 101 + dismissedLabel: t.reviewReports.dismissedLabel, 102 + hiddenLabel: t.reviewReports.hiddenLabel, 103 + removedLabel: t.reviewReports.removedLabel, 104 + restoredLabel: t.reviewReports.restoredLabel, 105 + notePlaceholder: t.reviewReports.notePlaceholder, 106 + reasonLabel: t.reviewReports.reasonLabel, 107 + reporterLabel: t.reviewReports.reporterLabel, 108 + reviewerLabel: t.reviewReports.reviewerLabel, 109 + detailsLabel: t.reviewReports.detailsLabel, 110 + reviewLabel: t.reviewReports.reviewLabel, 111 + submittedAt: t.reviewReports.submittedAt, 112 + error: t.errorPrefix, 113 + }} 114 + /> 115 + ))} 116 + </div> 117 + )} 118 + </div> 119 + </section> 120 + <Footer variant="compact" /> 121 + </div> 122 + </div> 123 + ); 124 + }
+45
routes/api/admin/review-reports/[id]/resolve.ts
··· 1 + /** 2 + * Admin: resolve an open review report. 3 + * 4 + * POST /api/admin/review-reports/:id/resolve 5 + * { action: 'actioned' | 'dismissed', notes?: string } 6 + */ 7 + import { define } from "../../../../../utils.ts"; 8 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 9 + import { resolveReviewReport } from "../../../../../lib/reviews.ts"; 10 + 11 + export const handler = define.handlers({ 12 + async POST(ctx) { 13 + const gate = requireAdminApi(ctx); 14 + if (!gate.ok) return gate.response; 15 + 16 + const id = Number(ctx.params.id); 17 + if (!Number.isFinite(id) || id <= 0) { 18 + return jsonError(400, "invalid_id"); 19 + } 20 + const body = await ctx.req.json().catch(() => null) as 21 + | { action?: unknown; notes?: unknown } 22 + | null; 23 + const action = body?.action; 24 + if (action !== "actioned" && action !== "dismissed") { 25 + return jsonError(400, "invalid_action"); 26 + } 27 + const notes = typeof body?.notes === "string" 28 + ? body.notes.trim().slice(0, 1000) || null 29 + : null; 30 + 31 + await resolveReviewReport(id, gate.did, action, notes); 32 + return jsonResponse(200, { ok: true }); 33 + }, 34 + }); 35 + 36 + function jsonResponse(status: number, body: unknown): Response { 37 + return new Response(JSON.stringify(body), { 38 + status, 39 + headers: { "content-type": "application/json; charset=utf-8" }, 40 + }); 41 + } 42 + 43 + function jsonError(status: number, code: string): Response { 44 + return jsonResponse(status, { error: code }); 45 + }
+43
routes/api/admin/reviews/[reviewId]/hide.ts
··· 1 + /** 2 + * Admin: hide a review from public UI and aggregates while retaining it. 3 + */ 4 + import { define } from "../../../../../utils.ts"; 5 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 6 + import { moderateReview } from "../../../../../lib/reviews.ts"; 7 + 8 + export const handler = define.handlers({ 9 + async POST(ctx) { 10 + const gate = requireAdminApi(ctx); 11 + if (!gate.ok) return gate.response; 12 + return await moderate(ctx.params.reviewId, gate.did, "hide", ctx.req); 13 + }, 14 + }); 15 + 16 + async function moderate( 17 + rawId: string | undefined, 18 + adminDid: string, 19 + action: "hide" | "remove" | "restore", 20 + req: Request, 21 + ): Promise<Response> { 22 + const id = Number(rawId); 23 + if (!Number.isFinite(id) || id <= 0) return jsonError(400, "invalid_id"); 24 + const body = await req.json().catch(() => null) as 25 + | { notes?: unknown } 26 + | null; 27 + const notes = typeof body?.notes === "string" 28 + ? body.notes.trim().slice(0, 1000) || null 29 + : null; 30 + const ok = await moderateReview(id, adminDid, action, notes); 31 + return ok ? jsonResponse(200, { ok: true }) : jsonError(404, "not_found"); 32 + } 33 + 34 + function jsonResponse(status: number, body: unknown): Response { 35 + return new Response(JSON.stringify(body), { 36 + status, 37 + headers: { "content-type": "application/json; charset=utf-8" }, 38 + }); 39 + } 40 + 41 + function jsonError(status: number, code: string): Response { 42 + return jsonResponse(status, { error: code }); 43 + }
+42
routes/api/admin/reviews/[reviewId]/remove.ts
··· 1 + /** 2 + * Admin: remove a review from public UI and aggregates. 3 + */ 4 + import { define } from "../../../../../utils.ts"; 5 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 6 + import { moderateReview } from "../../../../../lib/reviews.ts"; 7 + 8 + export const handler = define.handlers({ 9 + async POST(ctx) { 10 + const gate = requireAdminApi(ctx); 11 + if (!gate.ok) return gate.response; 12 + return await moderate(ctx.params.reviewId, gate.did, ctx.req); 13 + }, 14 + }); 15 + 16 + async function moderate( 17 + rawId: string | undefined, 18 + adminDid: string, 19 + req: Request, 20 + ): Promise<Response> { 21 + const id = Number(rawId); 22 + if (!Number.isFinite(id) || id <= 0) return jsonError(400, "invalid_id"); 23 + const body = await req.json().catch(() => null) as 24 + | { notes?: unknown } 25 + | null; 26 + const notes = typeof body?.notes === "string" 27 + ? body.notes.trim().slice(0, 1000) || null 28 + : null; 29 + const ok = await moderateReview(id, adminDid, "remove", notes); 30 + return ok ? jsonResponse(200, { ok: true }) : jsonError(404, "not_found"); 31 + } 32 + 33 + function jsonResponse(status: number, body: unknown): Response { 34 + return new Response(JSON.stringify(body), { 35 + status, 36 + headers: { "content-type": "application/json; charset=utf-8" }, 37 + }); 38 + } 39 + 40 + function jsonError(status: number, code: string): Response { 41 + return jsonResponse(status, { error: code }); 42 + }
+42
routes/api/admin/reviews/[reviewId]/restore.ts
··· 1 + /** 2 + * Admin: restore a hidden or removed review. 3 + */ 4 + import { define } from "../../../../../utils.ts"; 5 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 6 + import { moderateReview } from "../../../../../lib/reviews.ts"; 7 + 8 + export const handler = define.handlers({ 9 + async POST(ctx) { 10 + const gate = requireAdminApi(ctx); 11 + if (!gate.ok) return gate.response; 12 + return await moderate(ctx.params.reviewId, gate.did, ctx.req); 13 + }, 14 + }); 15 + 16 + async function moderate( 17 + rawId: string | undefined, 18 + adminDid: string, 19 + req: Request, 20 + ): Promise<Response> { 21 + const id = Number(rawId); 22 + if (!Number.isFinite(id) || id <= 0) return jsonError(400, "invalid_id"); 23 + const body = await req.json().catch(() => null) as 24 + | { notes?: unknown } 25 + | null; 26 + const notes = typeof body?.notes === "string" 27 + ? body.notes.trim().slice(0, 1000) || null 28 + : null; 29 + const ok = await moderateReview(id, adminDid, "restore", notes); 30 + return ok ? jsonResponse(200, { ok: true }) : jsonError(404, "not_found"); 31 + } 32 + 33 + function jsonResponse(status: number, body: unknown): Response { 34 + return new Response(JSON.stringify(body), { 35 + status, 36 + headers: { "content-type": "application/json; charset=utf-8" }, 37 + }); 38 + } 39 + 40 + function jsonError(status: number, code: string): Response { 41 + return jsonResponse(status, { error: code }); 42 + }
+68
routes/api/registry/profile.ts
··· 17 17 } from "../../../lib/pds.ts"; 18 18 import { 19 19 ATMOSPHERE_LINK_KINDS, 20 + type BlobRef, 20 21 type LinkEntry, 21 22 type ProfileRecord, 23 + type ScreenshotEntry, 22 24 validateProfile, 23 25 } from "../../../lib/lexicons.ts"; 24 26 import { ··· 29 31 import { sanitizeSvgBytes } from "../../../lib/svg-sanitize.ts"; 30 32 31 33 const ICON_MAX_BYTES = 200_000; 34 + const SCREENSHOT_MAX_BYTES = 5_000_000; 35 + const SCREENSHOT_MAX_COUNT = 4; 36 + const SCREENSHOT_MIME_TYPES = new Set([ 37 + "image/png", 38 + "image/jpeg", 39 + "image/webp", 40 + ]); 32 41 33 42 interface LinkPayload { 34 43 kind?: string; ··· 70 79 size: number; 71 80 } | null; 72 81 iconUpload?: { dataBase64: string; mimeType: string }; 82 + /** Existing screenshots to keep plus new uploads to append. */ 83 + screenshots?: ScreenshotEntry[]; 84 + screenshotUploads?: { dataBase64: string; mimeType: string }[]; 73 85 } 74 86 75 87 function trimOrNull(s: unknown): string | undefined { ··· 131 143 return out; 132 144 } 133 145 146 + function isImageBlobRef(v: unknown): v is BlobRef { 147 + if (!v || typeof v !== "object") return false; 148 + const b = v as Record<string, unknown>; 149 + const ref = b.ref as Record<string, unknown> | undefined; 150 + return b.$type === "blob" && 151 + !!ref && 152 + typeof ref.$link === "string" && 153 + typeof b.mimeType === "string" && 154 + SCREENSHOT_MIME_TYPES.has(b.mimeType) && 155 + typeof b.size === "number" && 156 + b.size <= SCREENSHOT_MAX_BYTES; 157 + } 158 + 134 159 export const handler = define.handlers({ 135 160 async PUT(ctx) { 136 161 const user = ctx.state.user; ··· 249 274 } 250 275 } 251 276 277 + const screenshots: ScreenshotEntry[] = []; 278 + if (Array.isArray(body.screenshots)) { 279 + for (const entry of body.screenshots) { 280 + if (screenshots.length >= SCREENSHOT_MAX_COUNT) break; 281 + if (entry && isImageBlobRef(entry.image)) { 282 + screenshots.push({ image: entry.image }); 283 + } 284 + } 285 + } 286 + const uploads = Array.isArray(body.screenshotUploads) 287 + ? body.screenshotUploads 288 + : []; 289 + if (screenshots.length + uploads.length > SCREENSHOT_MAX_COUNT) { 290 + return new Response(`screenshots: at most ${SCREENSHOT_MAX_COUNT}`, { 291 + status: 400, 292 + }); 293 + } 294 + for (const upload of uploads) { 295 + if (!SCREENSHOT_MIME_TYPES.has(upload.mimeType)) { 296 + return new Response("screenshots must be png, jpeg, or webp", { 297 + status: 400, 298 + }); 299 + } 300 + const bytes = decodeBase64(upload.dataBase64); 301 + if (bytes.byteLength > SCREENSHOT_MAX_BYTES) { 302 + return new Response("screenshot exceeds 5MB", { status: 400 }); 303 + } 304 + try { 305 + const image = await uploadBlob( 306 + user.did, 307 + session.pdsUrl, 308 + bytes, 309 + upload.mimeType, 310 + ); 311 + screenshots.push({ image }); 312 + } catch (err) { 313 + const m = err instanceof Error ? err.message : String(err); 314 + return new Response(`screenshot upload failed: ${m}`, { status: 502 }); 315 + } 316 + } 317 + 252 318 // Dedupe categories defensively. The lexicon validator below also 253 319 // does this, but normalising here means we surface a clean 400 ("at 254 320 // least one category") instead of a validator error string. ··· 291 357 links: links.length > 0 ? links : undefined, 292 358 avatar: avatar ?? undefined, 293 359 icon: icon ?? undefined, 360 + screenshots: screenshots.length > 0 ? screenshots : undefined, 294 361 createdAt: new Date().toISOString(), 295 362 }; 296 363 ··· 331 398 categories: validation.value.categories, 332 399 subcategories: validation.value.subcategories ?? [], 333 400 links: validation.value.links ?? [], 401 + screenshots: validation.value.screenshots ?? [], 334 402 avatarCid: validation.value.avatar?.ref.$link ?? null, 335 403 avatarMime: validation.value.avatar?.mimeType ?? null, 336 404 iconCid: validation.value.icon?.ref.$link ?? null,
+110
routes/api/registry/profile/[id]/reviews.ts
··· 1 + /** 2 + * Public list + signed-in create/update for reviews on a registry profile. 3 + * 4 + * GET /api/registry/profile/:id/reviews 5 + * POST /api/registry/profile/:id/reviews { rating, body? } 6 + */ 7 + import { define } from "../../../../../utils.ts"; 8 + import { withRateLimit } from "../../../../../lib/rate-limit.ts"; 9 + import { 10 + getProfileByDid, 11 + getProfileByHandle, 12 + } from "../../../../../lib/registry.ts"; 13 + import { 14 + createOrUpdateReview, 15 + getOwnReview, 16 + getReviewSummary, 17 + listVisibleReviews, 18 + normalizeReviewBody, 19 + validateReviewRating, 20 + } from "../../../../../lib/reviews.ts"; 21 + 22 + interface ReviewPayload { 23 + rating?: unknown; 24 + body?: unknown; 25 + } 26 + 27 + export const handler = define.handlers({ 28 + GET: withRateLimit(async (ctx) => { 29 + const target = await resolveTarget(ctx.params.id); 30 + if (!target) return jsonError(404, "not_found"); 31 + 32 + const url = new URL(ctx.req.url); 33 + const cursorRaw = url.searchParams.get("cursor"); 34 + const cursor = cursorRaw ? Number(cursorRaw) : undefined; 35 + const limitRaw = url.searchParams.get("limit"); 36 + const limit = limitRaw ? Number(limitRaw) : undefined; 37 + const [summary, reviews, ownReview] = await Promise.all([ 38 + getReviewSummary(target.did), 39 + listVisibleReviews(target.did, { cursor, limit }), 40 + ctx.state.user 41 + ? getOwnReview(target.did, ctx.state.user.did).catch(() => null) 42 + : Promise.resolve(null), 43 + ]); 44 + 45 + return jsonResponse(200, { 46 + summary, 47 + reviews, 48 + ownReview: ownReview?.status === "visible" ? ownReview : null, 49 + }, { 50 + "cache-control": ctx.state.user 51 + ? "private, max-age=0" 52 + : "public, max-age=30, s-maxage=120", 53 + }); 54 + }), 55 + 56 + POST: withRateLimit(async (ctx) => { 57 + const user = ctx.state.user; 58 + if (!user) return jsonError(401, "not_authenticated"); 59 + 60 + const target = await resolveTarget(ctx.params.id); 61 + if (!target) return jsonError(404, "not_found"); 62 + if (target.did === user.did) return jsonError(400, "cannot_review_self"); 63 + 64 + const body = await ctx.req.json().catch(() => null) as 65 + | ReviewPayload 66 + | null; 67 + if (!body) return jsonError(400, "invalid_body"); 68 + 69 + const rating = validateReviewRating(body.rating); 70 + if (!rating) return jsonError(400, "invalid_rating"); 71 + 72 + const reviewBody = normalizeReviewBody(body.body); 73 + if (reviewBody == null) return jsonError(400, "body_too_long"); 74 + 75 + const review = await createOrUpdateReview({ 76 + targetDid: target.did, 77 + reviewerDid: user.did, 78 + rating, 79 + body: reviewBody, 80 + }); 81 + const summary = await getReviewSummary(target.did); 82 + return jsonResponse(200, { ok: true, review, summary }); 83 + }), 84 + }); 85 + 86 + async function resolveTarget(id: string | undefined) { 87 + const raw = decodeURIComponent(id ?? "").trim(); 88 + if (!raw) return null; 89 + return raw.startsWith("did:") 90 + ? await getProfileByDid(raw).catch(() => null) 91 + : await getProfileByHandle(raw.toLowerCase()).catch(() => null); 92 + } 93 + 94 + function jsonResponse( 95 + status: number, 96 + body: unknown, 97 + headers: Record<string, string> = {}, 98 + ): Response { 99 + return new Response(JSON.stringify(body), { 100 + status, 101 + headers: { 102 + "content-type": "application/json; charset=utf-8", 103 + ...headers, 104 + }, 105 + }); 106 + } 107 + 108 + function jsonError(status: number, code: string): Response { 109 + return jsonResponse(status, { error: code }); 110 + }
+48
routes/api/registry/profile/[id]/reviews/me.ts
··· 1 + /** 2 + * Signed-in caller actions for their own review on a profile. 3 + * 4 + * DELETE /api/registry/profile/:id/reviews/me 5 + */ 6 + import { define } from "../../../../../../utils.ts"; 7 + import { withRateLimit } from "../../../../../../lib/rate-limit.ts"; 8 + import { 9 + getProfileByDid, 10 + getProfileByHandle, 11 + } from "../../../../../../lib/registry.ts"; 12 + import { 13 + deleteOwnReview, 14 + getReviewSummary, 15 + } from "../../../../../../lib/reviews.ts"; 16 + 17 + export const handler = define.handlers({ 18 + DELETE: withRateLimit(async (ctx) => { 19 + const user = ctx.state.user; 20 + if (!user) return jsonError(401, "not_authenticated"); 21 + 22 + const target = await resolveTarget(ctx.params.id); 23 + if (!target) return jsonError(404, "not_found"); 24 + 25 + const removed = await deleteOwnReview(target.did, user.did); 26 + const summary = await getReviewSummary(target.did); 27 + return jsonResponse(200, { ok: true, removed, summary }); 28 + }), 29 + }); 30 + 31 + async function resolveTarget(id: string | undefined) { 32 + const raw = decodeURIComponent(id ?? "").trim(); 33 + if (!raw) return null; 34 + return raw.startsWith("did:") 35 + ? await getProfileByDid(raw).catch(() => null) 36 + : await getProfileByHandle(raw.toLowerCase()).catch(() => null); 37 + } 38 + 39 + function jsonResponse(status: number, body: unknown): Response { 40 + return new Response(JSON.stringify(body), { 41 + status, 42 + headers: { "content-type": "application/json; charset=utf-8" }, 43 + }); 44 + } 45 + 46 + function jsonError(status: number, code: string): Response { 47 + return jsonResponse(status, { error: code }); 48 + }
+73
routes/api/registry/reviews/[reviewId]/report.ts
··· 1 + /** 2 + * Signed-in report submission for an individual review. 3 + * 4 + * POST /api/registry/reviews/:reviewId/report { reason, details? } 5 + */ 6 + import { define } from "../../../../../utils.ts"; 7 + import { withRateLimit } from "../../../../../lib/rate-limit.ts"; 8 + import { callerIp, hashIp } from "../../../../../lib/reports.ts"; 9 + import { 10 + createReviewReport, 11 + getReviewById, 12 + REVIEW_REPORT_REASONS, 13 + type ReviewReportReason, 14 + } from "../../../../../lib/reviews.ts"; 15 + 16 + interface ReportPayload { 17 + reason?: unknown; 18 + details?: unknown; 19 + } 20 + 21 + const MAX_DETAILS_LEN = 500; 22 + 23 + export const handler = define.handlers({ 24 + POST: withRateLimit(async (ctx) => { 25 + const user = ctx.state.user; 26 + if (!user) return jsonError(401, "not_authenticated"); 27 + 28 + const reviewId = Number(ctx.params.reviewId); 29 + if (!Number.isFinite(reviewId) || reviewId <= 0) { 30 + return jsonError(400, "invalid_review_id"); 31 + } 32 + const review = await getReviewById(reviewId); 33 + if (!review || review.status !== "visible") { 34 + return jsonError(404, "not_found"); 35 + } 36 + 37 + const body = await ctx.req.json().catch(() => null) as 38 + | ReportPayload 39 + | null; 40 + if (!body) return jsonError(400, "invalid_body"); 41 + 42 + const reason = typeof body.reason === "string" ? body.reason : ""; 43 + if (!(REVIEW_REPORT_REASONS as readonly string[]).includes(reason)) { 44 + return jsonError(400, "invalid_reason"); 45 + } 46 + const details = typeof body.details === "string" 47 + ? body.details.trim().slice(0, MAX_DETAILS_LEN) || null 48 + : null; 49 + 50 + const ip = callerIp(ctx.req); 51 + const ipHash = ip === "anonymous" ? null : await hashIp(ip); 52 + const result = await createReviewReport({ 53 + reviewId, 54 + reporterDid: user.did, 55 + ipHash, 56 + reason: reason as ReviewReportReason, 57 + details, 58 + }); 59 + 60 + return jsonResponse(200, { ok: true, deduped: result.ok === false }); 61 + }), 62 + }); 63 + 64 + function jsonResponse(status: number, body: unknown): Response { 65 + return new Response(JSON.stringify(body), { 66 + status, 67 + headers: { "content-type": "application/json; charset=utf-8" }, 68 + }); 69 + } 70 + 71 + function jsonError(status: number, code: string): Response { 72 + return jsonResponse(status, { error: code }); 73 + }
+87
routes/api/registry/reviews/[reviewId]/response.ts
··· 1 + /** 2 + * Profile-owner response to a review. 3 + * 4 + * PUT /api/registry/reviews/:reviewId/response { body } 5 + * DELETE /api/registry/reviews/:reviewId/response 6 + */ 7 + import { define } from "../../../../../utils.ts"; 8 + import { withRateLimit } from "../../../../../lib/rate-limit.ts"; 9 + import { getProfileByDid } from "../../../../../lib/registry.ts"; 10 + import { 11 + deleteReviewResponse, 12 + getReviewById, 13 + normalizeReviewResponseBody, 14 + upsertReviewResponse, 15 + } from "../../../../../lib/reviews.ts"; 16 + 17 + interface ResponsePayload { 18 + body?: unknown; 19 + } 20 + 21 + export const handler = define.handlers({ 22 + PUT: withRateLimit(async (ctx) => { 23 + const gate = await ownerGate(ctx.params.reviewId, ctx.state.user?.did); 24 + if (!gate.ok) return gate.response; 25 + 26 + const body = await ctx.req.json().catch(() => null) as 27 + | ResponsePayload 28 + | null; 29 + const responseBody = normalizeReviewResponseBody(body?.body); 30 + if (!responseBody) return jsonError(400, "invalid_body"); 31 + 32 + await upsertReviewResponse({ 33 + reviewId: gate.review.id, 34 + responderDid: gate.did, 35 + body: responseBody, 36 + }); 37 + const review = await getReviewById(gate.review.id); 38 + return jsonResponse(200, { ok: true, review }); 39 + }), 40 + 41 + DELETE: withRateLimit(async (ctx) => { 42 + const gate = await ownerGate(ctx.params.reviewId, ctx.state.user?.did); 43 + if (!gate.ok) return gate.response; 44 + 45 + const deleted = await deleteReviewResponse(gate.review.id, gate.did); 46 + const review = await getReviewById(gate.review.id); 47 + return jsonResponse(200, { ok: true, deleted, review }); 48 + }), 49 + }); 50 + 51 + async function ownerGate( 52 + rawReviewId: string | undefined, 53 + did: string | undefined, 54 + ): Promise< 55 + | { 56 + ok: true; 57 + did: string; 58 + review: NonNullable<Awaited<ReturnType<typeof getReviewById>>>; 59 + } 60 + | { ok: false; response: Response } 61 + > { 62 + if (!did) return { ok: false, response: jsonError(401, "not_authenticated") }; 63 + const reviewId = Number(rawReviewId); 64 + if (!Number.isFinite(reviewId) || reviewId <= 0) { 65 + return { ok: false, response: jsonError(400, "invalid_review_id") }; 66 + } 67 + const review = await getReviewById(reviewId); 68 + if (!review || review.status !== "visible") { 69 + return { ok: false, response: jsonError(404, "not_found") }; 70 + } 71 + const profile = await getProfileByDid(review.targetDid).catch(() => null); 72 + if (!profile || profile.did !== did) { 73 + return { ok: false, response: jsonError(403, "forbidden") }; 74 + } 75 + return { ok: true, did, review }; 76 + } 77 + 78 + function jsonResponse(status: number, body: unknown): Response { 79 + return new Response(JSON.stringify(body), { 80 + status, 81 + headers: { "content-type": "application/json; charset=utf-8" }, 82 + }); 83 + } 84 + 85 + function jsonError(status: number, code: string): Response { 86 + return jsonResponse(status, { error: code }); 87 + }
+47
routes/api/registry/screenshot/[did]/[index].ts
··· 1 + /** 2 + * Proxy + cache a profile screenshot blob. Screenshots are only rendered on 3 + * profile detail pages and are lazy-loaded by the browser, so list pages never 4 + * pull these image bytes. 5 + */ 6 + import { define } from "../../../../../utils.ts"; 7 + import { getProfileByDid } from "../../../../../lib/registry.ts"; 8 + import { fetchBlobPublic } from "../../../../../lib/pds.ts"; 9 + import { withRateLimit } from "../../../../../lib/rate-limit.ts"; 10 + 11 + export const handler = define.handlers({ 12 + GET: withRateLimit(async (ctx) => { 13 + const did = decodeURIComponent(ctx.params.did); 14 + const index = Number(ctx.params.index); 15 + if (!Number.isInteger(index) || index < 0 || index >= 4) { 16 + return new Response("not found", { status: 404 }); 17 + } 18 + const profile = await getProfileByDid(did).catch(() => null); 19 + const screenshot = profile?.screenshots[index]; 20 + if (!profile || !screenshot) { 21 + return new Response("not found", { status: 404 }); 22 + } 23 + try { 24 + const cid = screenshot.image.ref.$link; 25 + const upstream = await fetchBlobPublic(profile.pdsUrl, did, cid); 26 + if (!upstream.ok) { 27 + return new Response("not found", { status: 404 }); 28 + } 29 + const headers = new Headers(); 30 + headers.set( 31 + "content-type", 32 + upstream.headers.get("content-type") ?? 33 + screenshot.image.mimeType ?? 34 + "application/octet-stream", 35 + ); 36 + headers.set( 37 + "cache-control", 38 + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 39 + ); 40 + headers.set("etag", cid); 41 + return new Response(upstream.body, { status: 200, headers }); 42 + } catch (err) { 43 + console.warn("screenshot proxy error:", err); 44 + return new Response("upstream error", { status: 502 }); 45 + } 46 + }), 47 + });
+5 -5
routes/explore.tsx
··· 13 13 type ProfileRow, 14 14 searchProfiles, 15 15 } from "../lib/registry.ts"; 16 - import { CATEGORIES } from "../lib/lexicons.ts"; 16 + import { PUBLIC_CATEGORIES } from "../lib/lexicons.ts"; 17 17 import { buildAccountMenuProps } from "../lib/account-menu-props.ts"; 18 18 19 19 interface ExploreData { ··· 33 33 async GET(ctx) { 34 34 const url = ctx.url; 35 35 const rawCategory = url.searchParams.get("category"); 36 - const category = 37 - rawCategory && (CATEGORIES as readonly string[]).includes(rawCategory) 38 - ? rawCategory 39 - : null; 36 + const category = rawCategory && 37 + (PUBLIC_CATEGORIES as readonly string[]).includes(rawCategory) 38 + ? rawCategory 39 + : null; 40 40 const subcategory = url.searchParams.get("subcategory"); 41 41 const query = url.searchParams.get("q")?.trim() ?? ""; 42 42 const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1);
+117 -2
routes/explore/[handle].tsx
··· 4 4 import Footer from "../../components/Footer.tsx"; 5 5 import ProfileHero from "../../components/explore/ProfileHero.tsx"; 6 6 import ProfileLinks from "../../components/explore/ProfileLinks.tsx"; 7 + import ProfileScreenshots from "../../components/explore/ProfileScreenshots.tsx"; 8 + import ProfileRatingSummary from "../../components/explore/ProfileRatingSummary.tsx"; 9 + import ProfileReviewList, { 10 + type DisplayReview, 11 + } from "../../components/explore/ProfileReviewList.tsx"; 12 + import ProfileReviewComposer from "../../islands/ProfileReviewComposer.tsx"; 7 13 import ReportProfileButton from "../../islands/ReportProfileButton.tsx"; 8 14 import { getMessages } from "../../i18n/mod.ts"; 9 15 import type { Locale } from "../../i18n/mod.ts"; ··· 12 18 getProfileByHandle, 13 19 type ProfileRow, 14 20 } from "../../lib/registry.ts"; 21 + import { 22 + getOwnReview, 23 + getReviewSummary, 24 + listVisibleReviews, 25 + type ReviewRow, 26 + type ReviewSummary, 27 + } from "../../lib/reviews.ts"; 15 28 import { accountProviderName } from "../../lib/account-providers.ts"; 16 29 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 17 30 ··· 29 42 null, 30 43 ), 31 44 ]); 45 + const [reviewSummary, reviews, ownReview] = profile 46 + ? await Promise.all([ 47 + getReviewSummary(profile.did).catch(() => emptyReviewSummary()), 48 + listVisibleReviews(profile.did, { limit: 20 }).catch(() => []), 49 + user ? getOwnReview(profile.did, user.did).catch(() => null) : null, 50 + ]) 51 + : [emptyReviewSummary(), [] as ReviewRow[], null]; 52 + const displayReviews = profile ? await enrichReviews(reviews) : []; 32 53 return ctx.render( 33 54 <ProfileDetailPage 34 55 profile={profile} 56 + reviewSummary={reviewSummary} 57 + reviews={displayReviews} 58 + ownReview={ownReview?.status === "visible" ? ownReview : null} 35 59 signedInUser={user ? { did: user.did, handle: user.handle } : null} 36 60 account={buildAccountMenuProps(ctx.state, ownerProfile?.handle ?? null)} 37 61 ownerHandle={ownerProfile?.handle ?? null} ··· 44 68 45 69 interface DetailProps { 46 70 profile: ProfileRow | null; 71 + reviewSummary: ReviewSummary; 72 + reviews: DisplayReview[]; 73 + ownReview: ReviewRow | null; 47 74 signedInUser: { did: string; handle: string } | null; 48 75 account: ReturnType<typeof buildAccountMenuProps>; 49 76 ownerHandle: string | null; ··· 51 78 } 52 79 53 80 function ProfileDetailPage( 54 - { profile, signedInUser, account, ownerHandle: _ownerHandle, locale }: 55 - DetailProps, 81 + { 82 + profile, 83 + reviewSummary, 84 + reviews, 85 + ownReview, 86 + signedInUser, 87 + account, 88 + ownerHandle: _ownerHandle, 89 + locale, 90 + }: DetailProps, 56 91 ) { 57 92 const messages = getMessages(locale); 58 93 const t = messages.explore; ··· 87 122 <ProfileHero profile={profile} /> 88 123 </div> 89 124 <ProfileLinks profile={profile} /> 125 + <ProfileScreenshots profile={profile} /> 126 + 127 + <div class="profile-reviews-shell"> 128 + <ProfileRatingSummary 129 + summary={reviewSummary} 130 + copy={{ 131 + heading: messages.reviews.summary.heading, 132 + threshold: messages.reviews.summary.threshold, 133 + average: messages.reviews.summary.average, 134 + distributionLabel: messages.reviews.summary.distributionLabel, 135 + }} 136 + /> 137 + <ProfileReviewList 138 + reviews={reviews} 139 + signedIn={!!signedInUser} 140 + isOwner={isOwner} 141 + action={ 142 + <ProfileReviewComposer 143 + targetId={profile.handle} 144 + signedIn={!!signedInUser} 145 + isOwner={isOwner} 146 + loginHref={`/oauth/login?next=${ 147 + encodeURIComponent(`/explore/${profile.handle}`) 148 + }`} 149 + ownReview={ownReview 150 + ? { 151 + id: ownReview.id, 152 + rating: ownReview.rating, 153 + body: ownReview.body, 154 + } 155 + : null} 156 + copy={{ 157 + heading: messages.reviews.composer.heading, 158 + modalBody: messages.reviews.composer.modalBody, 159 + signedOut: messages.reviews.composer.signedOut, 160 + ownerNote: messages.reviews.composer.ownerNote, 161 + ratingLabel: messages.reviews.composer.ratingLabel, 162 + bodyLabel: messages.reviews.composer.bodyLabel, 163 + bodyPlaceholder: 164 + messages.reviews.composer.bodyPlaceholder, 165 + charsRemainingSuffix: 166 + messages.reviews.composer.charsRemainingSuffix, 167 + submit: messages.reviews.composer.submit, 168 + update: messages.reviews.composer.update, 169 + submitting: messages.reviews.composer.submitting, 170 + delete: messages.reviews.composer.delete, 171 + signIn: messages.reviews.composer.signIn, 172 + cancel: messages.reviews.composer.cancel, 173 + saved: messages.reviews.composer.saved, 174 + deleted: messages.reviews.composer.deleted, 175 + error: messages.reviews.composer.error, 176 + }} 177 + /> 178 + } 179 + copy={{ 180 + heading: messages.reviews.list.heading, 181 + empty: messages.reviews.list.empty, 182 + reviewerFallback: messages.reviews.list.reviewerFallback, 183 + edited: messages.reviews.list.edited, 184 + ownerResponse: messages.reviews.list.ownerResponse, 185 + report: messages.reviews.report, 186 + response: messages.reviews.response, 187 + }} 188 + /> 189 + </div> 90 190 91 191 {isOwner && ( 92 192 <p style={{ marginTop: "1.5rem" }}> ··· 132 232 <Footer variant="compact" /> 133 233 </div> 134 234 </div> 235 + ); 236 + } 237 + 238 + function emptyReviewSummary(): ReviewSummary { 239 + return { visibleCount: 0, averageRating: null, distribution: null }; 240 + } 241 + 242 + async function enrichReviews(reviews: ReviewRow[]): Promise<DisplayReview[]> { 243 + return await Promise.all( 244 + reviews.map(async (review) => { 245 + const profile = await getProfileByDid(review.reviewerDid).catch(() => 246 + null 247 + ); 248 + return { ...review, reviewerHandle: profile?.handle ?? null }; 249 + }), 135 250 ); 136 251 } 137 252
+6
routes/explore/manage.tsx
··· 59 59 categories: existing.categories, 60 60 subcategories: existing.subcategories, 61 61 links: existing.links, 62 + screenshots: existing.screenshots.map((entry) => ({ 63 + ref: entry.image.ref.$link, 64 + mime: entry.image.mimeType, 65 + size: entry.image.size, 66 + })), 62 67 avatar: existing.avatarCid && existing.avatarMime 63 68 ? { ref: existing.avatarCid, mime: existing.avatarMime } 64 69 : null, ··· 88 93 categories: ["app"], 89 94 subcategories: [], 90 95 links: [], 96 + screenshots: [], 91 97 avatar: bsky.avatar 92 98 ? { 93 99 ref: bsky.avatar.ref.$link,
+65
static/profile-screenshot-carousel.js
··· 1 + const carouselSelector = "[data-screenshot-carousel]"; 2 + const directionSelector = "[data-screenshot-direction]"; 3 + 4 + function updateButtons(shell) { 5 + const track = shell.querySelector(".profile-screenshots-carousel"); 6 + if (!track) return; 7 + 8 + const maxScrollLeft = Math.max(0, track.scrollWidth - track.clientWidth); 9 + const atStart = track.scrollLeft <= 1; 10 + const atEnd = track.scrollLeft >= maxScrollLeft - 1; 11 + 12 + for (const button of shell.querySelectorAll(directionSelector)) { 13 + const isPrevious = button.dataset.screenshotDirection === "-1"; 14 + const disabled = maxScrollLeft <= 1 || (isPrevious ? atStart : atEnd); 15 + button.disabled = disabled; 16 + button.setAttribute("aria-disabled", disabled ? "true" : "false"); 17 + } 18 + } 19 + 20 + function scrollCarousel(button) { 21 + const shell = button.closest(carouselSelector); 22 + const track = shell?.querySelector(".profile-screenshots-carousel"); 23 + const firstCard = track?.querySelector(".profile-screenshot-card"); 24 + if (!track || !firstCard) return; 25 + 26 + const direction = button.dataset.screenshotDirection === "-1" ? -1 : 1; 27 + const gap = Number.parseFloat(getComputedStyle(track).columnGap || "0") || 0; 28 + const cardStep = firstCard.getBoundingClientRect().width + gap; 29 + const step = Math.min( 30 + Math.max(cardStep, 220), 31 + Math.max(track.clientWidth * 0.85, 220), 32 + ); 33 + 34 + track.scrollBy({ 35 + left: direction * step, 36 + behavior: "smooth", 37 + }); 38 + globalThis.setTimeout(() => updateButtons(shell), 350); 39 + } 40 + 41 + document.addEventListener("click", (event) => { 42 + const target = event.target; 43 + if (!(target instanceof Element)) return; 44 + 45 + const button = target.closest(directionSelector); 46 + if (!(button instanceof HTMLButtonElement)) return; 47 + 48 + event.preventDefault(); 49 + event.stopPropagation(); 50 + scrollCarousel(button); 51 + }); 52 + 53 + for (const shell of document.querySelectorAll(carouselSelector)) { 54 + updateButtons(shell); 55 + const track = shell.querySelector(".profile-screenshots-carousel"); 56 + track?.addEventListener("scroll", () => updateButtons(shell), { 57 + passive: true, 58 + }); 59 + } 60 + 61 + globalThis.addEventListener("resize", () => { 62 + for (const shell of document.querySelectorAll(carouselSelector)) { 63 + updateButtons(shell); 64 + } 65 + });
+1
worker/indexer.ts
··· 116 116 categories: r.categories, 117 117 subcategories: r.subcategories ?? [], 118 118 links: r.links ?? [], 119 + screenshots: r.screenshots ?? [], 119 120 avatarCid: r.avatar?.ref.$link ?? null, 120 121 avatarMime: r.avatar?.mimeType ?? null, 121 122 iconCid: r.icon?.ref.$link ?? null,