this repo has no description
0
fork

Configure Feed

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

feat(profile): refine profile review and update UI

Made-with: Cursor

+430 -167
+146 -28
assets/styles.css
··· 5020 5020 margin-top: 1.5rem; 5021 5021 } 5022 5022 .profile-whats-new { 5023 - margin-top: 1.25rem; 5024 - padding: 1.2rem; 5023 + margin-top: 1rem; 5024 + padding: 0.95rem 1rem; 5025 5025 border-radius: 1.2rem; 5026 5026 } 5027 5027 .profile-whats-new-main { ··· 5030 5030 align-items: flex-start; 5031 5031 gap: 1rem; 5032 5032 } 5033 + .profile-whats-new-copy { 5034 + min-width: 0; 5035 + } 5036 + .profile-whats-new-heading-row { 5037 + display: flex; 5038 + align-items: center; 5039 + justify-content: space-between; 5040 + gap: 0.75rem; 5041 + } 5033 5042 .profile-whats-new h2, 5034 5043 .profile-whats-new h3, 5035 5044 .profile-whats-new h4, ··· 5038 5047 } 5039 5048 .profile-whats-new h2 { 5040 5049 margin-top: 0.25rem; 5041 - font-size: clamp(1.2rem, 2.3vw, 1.55rem); 5050 + font-size: clamp(1rem, 1.8vw, 1.2rem); 5042 5051 } 5043 - .profile-whats-new-main p { 5052 + .profile-whats-new-heading-row .text-eyebrow, 5053 + .profile-reviews-eyebrow, 5054 + .profile-reviews-heading { 5055 + margin: 0; 5056 + font-size: 1rem; 5057 + font-weight: 800; 5058 + letter-spacing: 0; 5059 + text-transform: none; 5060 + color: rgba(18, 26, 47, 0.92); 5061 + } 5062 + .profile-whats-new-body, 5063 + .profile-whats-new-preview, 5064 + .profile-whats-new-full { 5044 5065 margin-top: 0.45rem; 5045 5066 color: rgba(18, 26, 47, 0.76); 5046 5067 white-space: pre-wrap; 5068 + font-size: 0.9rem; 5069 + line-height: 1.55; 5070 + } 5071 + .profile-whats-new-expand { 5072 + display: flex; 5073 + flex-direction: column; 5074 + } 5075 + .profile-whats-new-expand[open] .profile-whats-new-preview { 5076 + display: none; 5077 + } 5078 + .profile-whats-new-expand summary { 5079 + order: 2; 5080 + align-self: flex-start; 5081 + margin-top: 0.55rem; 5082 + list-style: none; 5083 + color: #254a9e; 5084 + font-size: 0.82rem; 5085 + font-weight: 750; 5086 + cursor: pointer; 5087 + } 5088 + .profile-whats-new-expand summary::-webkit-details-marker { 5089 + display: none; 5090 + } 5091 + .profile-whats-new-preview { 5092 + order: 1; 5093 + } 5094 + .profile-whats-new-full { 5095 + order: 3; 5047 5096 } 5048 5097 .profile-whats-new-empty { 5049 5098 color: rgba(18, 26, 47, 0.62); ··· 5056 5105 font-weight: 750; 5057 5106 color: rgba(18, 26, 47, 0.58); 5058 5107 } 5059 - .profile-whats-new-commit { 5108 + .profile-whats-new-icon-button, 5109 + .profile-whats-new-history-link, 5110 + .profile-version-history-commit { 5060 5111 flex: 0 0 auto; 5112 + display: inline-flex; 5113 + align-items: center; 5114 + justify-content: center; 5061 5115 text-decoration: none; 5062 - color: inherit; 5116 + color: #254a9e; 5117 + border: 1px solid rgba(18, 26, 47, 0.12); 5118 + background: rgba(255, 255, 255, 0.6); 5119 + border-radius: 999px; 5120 + } 5121 + .profile-whats-new-icon-button, 5122 + .profile-whats-new-history-link { 5123 + width: 2.15rem; 5124 + height: 2.15rem; 5125 + } 5126 + .profile-whats-new-icon-button { 5127 + position: relative; 5128 + } 5129 + .profile-whats-new-history-link { 5130 + font-size: 1rem; 5131 + } 5132 + .profile-whats-new-icon-button:hover, 5133 + .profile-whats-new-history-link:hover, 5134 + .profile-version-history-commit:hover { 5135 + background: rgba(255, 255, 255, 0.86); 5136 + border-color: rgba(42, 90, 168, 0.3); 5137 + } 5138 + .profile-whats-new-icon { 5139 + width: 1.15rem; 5140 + height: 1.15rem; 5141 + } 5142 + .profile-whats-new-icon-arrow { 5143 + position: absolute; 5144 + right: 0.25rem; 5145 + top: 0.18rem; 5146 + font-size: 0.62rem; 5147 + line-height: 1; 5063 5148 } 5064 5149 .profile-version-history { 5065 5150 margin-top: 1rem; 5066 5151 padding-top: 1rem; 5067 5152 border-top: 1px solid rgba(18, 26, 47, 0.1); 5153 + scroll-margin-top: 6rem; 5068 5154 } 5069 5155 .profile-version-history h3 { 5070 - font-size: 0.95rem; 5156 + font-size: 0.9rem; 5071 5157 margin-bottom: 0.75rem; 5072 5158 } 5073 5159 .profile-version-history-item { ··· 5086 5172 margin-top: 0.25rem; 5087 5173 color: rgba(18, 26, 47, 0.7); 5088 5174 white-space: pre-wrap; 5175 + font-size: 0.88rem; 5176 + } 5177 + .profile-version-history-commit { 5178 + gap: 0.25rem; 5179 + margin-top: 0.55rem; 5180 + padding: 0.32rem 0.55rem; 5089 5181 } 5090 5182 .profile-reviews-summary, 5091 5183 .profile-reviews-panel, ··· 5099 5191 gap: 1.2rem; 5100 5192 align-items: center; 5101 5193 } 5102 - .profile-reviews-eyebrow { 5103 - margin: 0; 5104 - font-size: 0.78rem; 5105 - font-weight: 800; 5106 - letter-spacing: 0.08em; 5107 - text-transform: uppercase; 5108 - color: rgba(18, 26, 47, 0.58); 5109 - } 5110 5194 .profile-reviews-average { 5111 5195 margin: 0.2rem 0 0; 5112 5196 font-size: clamp(1.3rem, 2.5vw, 1.9rem); ··· 5145 5229 border-radius: inherit; 5146 5230 background: linear-gradient(90deg, #d89a23, #f2c75c); 5147 5231 } 5148 - .profile-reviews-heading { 5149 - margin: 0; 5150 - font-size: 1rem; 5151 - } 5152 5232 .profile-reviews-panel-header { 5153 5233 display: flex; 5154 5234 flex-wrap: wrap; ··· 5163 5243 justify-content: flex-end; 5164 5244 align-items: center; 5165 5245 gap: 0.75rem; 5246 + } 5247 + .profile-review-write-button { 5248 + display: inline-flex; 5249 + align-items: center; 5250 + gap: 0.45rem; 5251 + } 5252 + .profile-review-write-icon { 5253 + font-size: 1rem; 5254 + line-height: 1; 5166 5255 } 5167 5256 .profile-review-action-hint, 5168 5257 .profile-review-owner-note { ··· 5317 5406 .profile-review-response-composer { 5318 5407 margin-top: 0.9rem; 5319 5408 } 5320 - .dark-phase .profile-reviews-eyebrow, 5321 5409 .dark-phase .profile-review-rating-field legend, 5322 5410 .dark-phase .profile-review-body-field span, 5323 5411 .dark-phase .profile-review-response-label { 5324 5412 color: rgba(255, 255, 255, 0.65); 5325 5413 } 5414 + .dark-phase .profile-whats-new-heading-row .text-eyebrow, 5415 + .dark-phase .profile-reviews-eyebrow, 5416 + .dark-phase .profile-reviews-heading { 5417 + color: rgba(255, 255, 255, 0.92); 5418 + } 5326 5419 .dark-phase .profile-reviews-threshold, 5327 5420 .dark-phase .profile-whats-new-empty, 5328 - .dark-phase .profile-whats-new-main p, 5421 + .dark-phase .profile-whats-new-body, 5422 + .dark-phase .profile-whats-new-preview, 5423 + .dark-phase .profile-whats-new-full, 5329 5424 .dark-phase .profile-version-history-item p, 5330 5425 .dark-phase .profile-whats-new-meta, 5331 5426 .dark-phase .profile-rating-row, ··· 5351 5446 .dark-phase .profile-review-avatar { 5352 5447 background: rgba(255, 255, 255, 0.12); 5353 5448 color: rgba(255, 255, 255, 0.72); 5449 + } 5450 + .dark-phase .profile-whats-new-expand summary, 5451 + .dark-phase .profile-whats-new-icon-button, 5452 + .dark-phase .profile-whats-new-history-link, 5453 + .dark-phase .profile-version-history-commit { 5454 + color: rgba(160, 200, 255, 1); 5455 + } 5456 + .dark-phase .profile-whats-new-icon-button, 5457 + .dark-phase .profile-whats-new-history-link, 5458 + .dark-phase .profile-version-history-commit { 5459 + background: rgba(255, 255, 255, 0.06); 5460 + border-color: rgba(255, 255, 255, 0.13); 5461 + } 5462 + .dark-phase .profile-whats-new-icon-button:hover, 5463 + .dark-phase .profile-whats-new-history-link:hover, 5464 + .dark-phase .profile-version-history-commit:hover { 5465 + background: rgba(255, 255, 255, 0.1); 5466 + border-color: rgba(160, 200, 255, 0.35); 5354 5467 } 5355 5468 .dark-phase .profile-version-history { 5356 5469 border-top-color: rgba(255, 255, 255, 0.12); ··· 5440 5553 align-items: center; 5441 5554 justify-content: space-between; 5442 5555 padding: 1.2rem; 5556 + } 5557 + .user-reviews-heading { 5558 + margin: 1.45rem 0 0.75rem; 5559 + font-size: clamp(1.15rem, 2vw, 1.45rem); 5443 5560 } 5444 5561 .user-profile-settings { 5445 5562 display: grid; ··· 5665 5782 padding: 1.5rem; 5666 5783 border-radius: 1.4rem; 5667 5784 } 5785 + .user-public-media { 5786 + display: flex; 5787 + flex: 0 0 auto; 5788 + flex-direction: column; 5789 + align-items: center; 5790 + gap: 0.7rem; 5791 + } 5668 5792 .user-public-body { 5669 5793 min-width: 0; 5670 5794 } 5671 5795 .user-public-client-link { 5672 - display: inline-flex; 5673 - margin-top: 1rem; 5674 - } 5675 - .user-public-client-link img { 5676 - width: 22px; 5677 - height: 22px; 5678 - border-radius: 0.4rem; 5796 + margin-top: 0; 5679 5797 } 5680 5798 @media (max-width: 640px) { 5681 5799 .user-public-card {
+62 -14
components/explore/ProfileWhatsNew.tsx
··· 1 1 import type { ProfileUpdateRow } from "../../lib/profile-updates.ts"; 2 + import TangledIcon from "../icons/TangledIcon.tsx"; 2 3 3 4 interface Props { 4 5 updates: ProfileUpdateRow[]; ··· 7 8 empty: string; 8 9 versionHistory: string; 9 10 viewCommit: string; 11 + readFullUpdate: string; 10 12 }; 11 13 } 14 + 15 + const BODY_PREVIEW_LENGTH = 220; 12 16 13 17 function dateLabel(ms: number): string { 14 18 return new Intl.DateTimeFormat("en", { ··· 29 33 ); 30 34 } 31 35 36 + function isLongBody(body: string): boolean { 37 + return body.length > BODY_PREVIEW_LENGTH || body.split("\n").length > 3; 38 + } 39 + 40 + function previewBody(body: string): string { 41 + if (!isLongBody(body)) return body; 42 + return `${body.slice(0, BODY_PREVIEW_LENGTH).trimEnd()}...`; 43 + } 44 + 45 + function CommitLink( 46 + { href, label }: { href: string; label: string }, 47 + ) { 48 + return ( 49 + <a 50 + href={href} 51 + target="_blank" 52 + rel="noopener noreferrer" 53 + class="profile-whats-new-icon-button" 54 + aria-label={label} 55 + title={label} 56 + > 57 + <TangledIcon class="profile-whats-new-icon" /> 58 + <span class="profile-whats-new-icon-arrow" aria-hidden="true">↗</span> 59 + </a> 60 + ); 61 + } 62 + 32 63 export default function ProfileWhatsNew({ updates, copy }: Props) { 33 64 const [latest, ...history] = updates; 34 65 if (!latest) { ··· 43 74 return ( 44 75 <section class="profile-whats-new glass"> 45 76 <div class="profile-whats-new-main"> 46 - <div> 47 - <p class="text-eyebrow">{copy.heading}</p> 77 + <div class="profile-whats-new-copy"> 78 + <div class="profile-whats-new-heading-row"> 79 + <p class="text-eyebrow">{copy.heading}</p> 80 + {history.length > 0 && ( 81 + <a 82 + href="#profile-version-history" 83 + class="profile-whats-new-history-link" 84 + aria-label={copy.versionHistory} 85 + title={copy.versionHistory} 86 + > 87 + <span aria-hidden="true">↺</span> 88 + </a> 89 + )} 90 + </div> 48 91 <UpdateMeta update={latest} /> 49 92 <h2>{latest.title}</h2> 50 - <p>{latest.body}</p> 93 + {isLongBody(latest.body) 94 + ? ( 95 + <details class="profile-whats-new-expand"> 96 + <p class="profile-whats-new-preview"> 97 + {previewBody(latest.body)} 98 + </p> 99 + <summary>{copy.readFullUpdate}</summary> 100 + <p class="profile-whats-new-full">{latest.body}</p> 101 + </details> 102 + ) 103 + : <p class="profile-whats-new-body">{latest.body}</p>} 51 104 </div> 52 105 {latest.tangledCommitUrl && ( 53 - <a 54 - href={latest.tangledCommitUrl} 55 - target="_blank" 56 - rel="noopener noreferrer" 57 - class="profile-form-button-secondary profile-whats-new-commit" 58 - > 59 - {copy.viewCommit} 60 - </a> 106 + <CommitLink href={latest.tangledCommitUrl} label={copy.viewCommit} /> 61 107 )} 62 108 </div> 63 109 64 110 {history.length > 0 && ( 65 - <div class="profile-version-history"> 111 + <div class="profile-version-history" id="profile-version-history"> 66 112 <h3>{copy.versionHistory}</h3> 67 113 {history.slice(0, 5).map((update) => ( 68 114 <article class="profile-version-history-item" key={update.uri}> ··· 74 120 href={update.tangledCommitUrl} 75 121 target="_blank" 76 122 rel="noopener noreferrer" 77 - class="text-link-button" 123 + class="profile-version-history-commit" 78 124 > 79 - {copy.viewCommit} 125 + <TangledIcon class="profile-whats-new-icon" /> 126 + <span aria-hidden="true">↗</span> 127 + <span class="visually-hidden">{copy.viewCommit}</span> 80 128 </a> 81 129 )} 82 130 </article>
+8 -2
i18n/messages/en.tsx
··· 504 504 empty: "No updates yet.", 505 505 versionHistory: "Version History", 506 506 viewCommit: "View commit", 507 + readFullUpdate: "Read full update", 507 508 }, 508 509 notFoundTitle: "404", 509 510 notFoundBody: "We couldn't find a profile for that handle.", ··· 931 932 932 933 accountReviews: { 933 934 eyebrow: "User account", 934 - headline: "Your reviews", 935 + headline: "Your Profile", 935 936 subhead: (handle: string): string => 936 937 `Signed in as @${handle}. Your user profile comes from Bluesky; reviews are managed here.`, 938 + reviewsHeading: "Your reviews", 937 939 empty: "You haven't reviewed any projects yet.", 938 940 explore: "Explore projects", 939 941 viewProfile: "View public profile", 940 - clientLabel: "Open Bluesky links with", 942 + clientLabel: "Bluesky button", 943 + displayBskyButton: "Display Bluesky button", 944 + configureBskyClient: "Choose Bluesky client", 941 945 saveClient: "Save", 946 + cancel: "Cancel", 947 + done: "Done", 942 948 viewProject: "View project", 943 949 delete: "Delete review", 944 950 deleting: "Deleting…",
+1 -1
islands/AccountMenu.tsx
··· 177 177 }} 178 178 > 179 179 {accountType === "user" 180 - ? t.manageReviews 180 + ? t.manageProfile 181 181 : accountType === "project" 182 182 ? t.manageProfile 183 183 : t.chooseAccountType}
+6 -1
islands/ProfileReviewComposer.tsx
··· 109 109 : ( 110 110 <button 111 111 type="button" 112 - class="explore-cta-primary" 112 + class="explore-cta-primary profile-review-write-button" 113 113 onClick={() => { 114 114 open.value = true; 115 115 }} 116 116 > 117 + {!ownReview && ( 118 + <span class="profile-review-write-icon" aria-hidden="true"> 119 + 120 + </span> 121 + )} 117 122 {ownReview ? copy.update : copy.heading} 118 123 </button> 119 124 )}
+143 -84
islands/UserBskyClientPicker.tsx
··· 1 - import { useEffect } from "preact/hooks"; 2 1 import { useSignal } from "@preact/signals"; 3 2 import { BSKY_CLIENTS, getBskyClient } from "../lib/bsky-clients.ts"; 4 3 5 4 interface Props { 6 5 selectedClientId: string | null; 6 + visible: boolean; 7 7 label: string; 8 + displayLabel: string; 9 + settingsLabel: string; 8 10 saveLabel: string; 11 + cancelLabel: string; 12 + doneLabel: string; 9 13 } 10 14 11 15 export default function UserBskyClientPicker( 12 - { selectedClientId, label, saveLabel }: Props, 16 + { 17 + selectedClientId, 18 + visible, 19 + label, 20 + displayLabel, 21 + settingsLabel, 22 + saveLabel, 23 + cancelLabel, 24 + doneLabel, 25 + }: Props, 13 26 ) { 14 27 const selected = useSignal(getBskyClient(selectedClientId).id); 15 - const open = useSignal(false); 16 - 17 - useEffect(() => { 18 - if (!open.value) return; 19 - 20 - const close = (event: MouseEvent) => { 21 - const target = event.target as HTMLElement | null; 22 - if (!target?.closest(".user-bsky-picker")) { 23 - open.value = false; 24 - } 25 - }; 26 - 27 - const onKey = (event: KeyboardEvent) => { 28 - if (event.key === "Escape") open.value = false; 29 - }; 30 - 31 - globalThis.addEventListener("click", close); 32 - globalThis.addEventListener("keydown", onKey); 33 - return () => { 34 - globalThis.removeEventListener("click", close); 35 - globalThis.removeEventListener("keydown", onKey); 36 - }; 37 - }, [open.value]); 28 + const draftSelected = useSignal(selected.value); 29 + const buttonVisible = useSignal(visible); 30 + const modalOpen = useSignal(false); 38 31 39 32 const active = getBskyClient(selected.value); 40 33 ··· 45 38 class="user-profile-client-form" 46 39 > 47 40 <input type="hidden" name="bskyClientId" value={selected.value} /> 41 + <input type="hidden" name="bskyButtonVisible" value="0" /> 48 42 <label class="user-bsky-picker-label" id="user-bsky-picker-label"> 49 43 {label} 50 44 </label> 51 - <div class="user-bsky-picker"> 45 + <div 46 + class={`atmosphere-row user-bsky-settings-row ${ 47 + buttonVisible.value ? "is-on" : "" 48 + }`} 49 + > 50 + <label class="atmosphere-row-toggle"> 51 + <input 52 + type="checkbox" 53 + name="bskyButtonVisible" 54 + value="1" 55 + checked={buttonVisible.value} 56 + onChange={(event) => 57 + buttonVisible.value = 58 + (event.currentTarget as HTMLInputElement).checked} 59 + aria-label={displayLabel} 60 + /> 61 + <span class="atmosphere-toggle-track" aria-hidden="true"> 62 + <span class="atmosphere-toggle-thumb" /> 63 + </span> 64 + </label> 65 + <div class="atmosphere-row-body"> 66 + <span class="atmosphere-row-icon"> 67 + <img 68 + src={active.iconUrl} 69 + alt="" 70 + class="atmosphere-icon" 71 + loading="lazy" 72 + decoding="async" 73 + /> 74 + </span> 75 + <span class="atmosphere-row-meta"> 76 + <span class="atmosphere-row-name">{displayLabel}</span> 77 + <span class="atmosphere-row-desc"> 78 + {active.name} · {active.domain} 79 + </span> 80 + </span> 81 + </div> 52 82 <button 53 83 type="button" 54 - class="user-bsky-picker-trigger" 55 - aria-haspopup="listbox" 56 - aria-expanded={open.value} 57 - aria-labelledby="user-bsky-picker-label" 58 - onClick={(event) => { 59 - event.stopPropagation(); 60 - open.value = !open.value; 84 + class="atmosphere-row-gear" 85 + aria-label={settingsLabel} 86 + title={settingsLabel} 87 + onClick={() => { 88 + draftSelected.value = selected.value; 89 + modalOpen.value = true; 61 90 }} 62 91 > 63 - <img 64 - src={active.iconUrl} 65 - alt="" 66 - class="bsky-client-icon" 67 - loading="lazy" 68 - decoding="async" 69 - /> 70 - <span class="bsky-client-meta"> 71 - <span class="bsky-client-name">{active.name}</span> 72 - <span class="bsky-client-domain">{active.domain}</span> 73 - </span> 74 - <span class="user-bsky-picker-chevron" aria-hidden="true">▾</span> 92 + 75 93 </button> 94 + </div> 76 95 77 - {open.value && ( 78 - <ul 79 - class="bsky-client-list user-bsky-picker-popover" 80 - role="listbox" 81 - aria-labelledby="user-bsky-picker-label" 82 - > 83 - {BSKY_CLIENTS.map((client) => { 84 - const isSelected = client.id === selected.value; 85 - return ( 86 - <li key={client.id}> 87 - <button 88 - type="button" 89 - class={`bsky-client-row ${isSelected ? "is-selected" : ""}`} 90 - role="option" 91 - aria-selected={isSelected} 92 - onClick={() => { 93 - selected.value = client.id; 94 - open.value = false; 95 - }} 96 - > 97 - <img 98 - src={client.iconUrl} 99 - alt="" 100 - class="bsky-client-icon" 101 - loading="lazy" 102 - decoding="async" 103 - /> 104 - <span class="bsky-client-meta"> 105 - <span class="bsky-client-name">{client.name}</span> 106 - <span class="bsky-client-domain">{client.domain}</span> 107 - </span> 108 - <span class="bsky-client-radio" aria-hidden="true" /> 109 - </button> 110 - </li> 111 - ); 112 - })} 113 - </ul> 114 - )} 115 - </div> 96 + {modalOpen.value && ( 97 + <div 98 + class="modal-backdrop" 99 + role="dialog" 100 + aria-modal="true" 101 + aria-labelledby="user-bsky-picker-title" 102 + onClick={(event) => { 103 + if (event.target === event.currentTarget) modalOpen.value = false; 104 + }} 105 + > 106 + <div class="modal-card"> 107 + <header class="modal-header"> 108 + <h2 id="user-bsky-picker-title" class="modal-title"> 109 + {settingsLabel} 110 + </h2> 111 + </header> 112 + <ul 113 + class="bsky-client-list" 114 + role="listbox" 115 + aria-labelledby="user-bsky-picker-title" 116 + > 117 + {BSKY_CLIENTS.map((client) => { 118 + const isSelected = client.id === draftSelected.value; 119 + return ( 120 + <li key={client.id}> 121 + <label 122 + class={`bsky-client-row ${ 123 + isSelected ? "is-selected" : "" 124 + }`} 125 + role="option" 126 + aria-selected={isSelected} 127 + > 128 + <input 129 + type="radio" 130 + name="draftBskyClient" 131 + value={client.id} 132 + checked={isSelected} 133 + onChange={() => draftSelected.value = client.id} 134 + /> 135 + <img 136 + src={client.iconUrl} 137 + alt="" 138 + class="bsky-client-icon" 139 + loading="lazy" 140 + decoding="async" 141 + /> 142 + <span class="bsky-client-meta"> 143 + <span class="bsky-client-name">{client.name}</span> 144 + <span class="bsky-client-domain">{client.domain}</span> 145 + </span> 146 + <span class="bsky-client-radio" aria-hidden="true" /> 147 + </label> 148 + </li> 149 + ); 150 + })} 151 + </ul> 152 + <footer class="modal-footer"> 153 + <button 154 + type="button" 155 + class="profile-form-button-secondary" 156 + onClick={() => modalOpen.value = false} 157 + > 158 + {cancelLabel} 159 + </button> 160 + <button 161 + type="button" 162 + class="profile-form-button-primary" 163 + onClick={() => { 164 + selected.value = draftSelected.value; 165 + buttonVisible.value = true; 166 + modalOpen.value = false; 167 + }} 168 + > 169 + {doneLabel} 170 + </button> 171 + </footer> 172 + </div> 173 + </div> 174 + )} 116 175 <button type="submit" class="profile-form-button-primary"> 117 176 {saveLabel} 118 177 </button>
+10 -3
lib/account-types.ts
··· 17 17 avatarCid: string | null; 18 18 avatarMime: string | null; 19 19 bskyClientId: string; 20 + bskyButtonVisible: boolean; 20 21 accountType: AccountType; 21 22 createdAt: number; 22 23 updatedAt: number; ··· 30 31 avatar_cid: string | null; 31 32 avatar_mime: string | null; 32 33 bsky_client_id: string | null; 34 + bsky_button_visible: number | null; 33 35 account_type: string; 34 36 created_at: number; 35 37 updated_at: number; ··· 48 50 avatarCid: row.avatar_cid, 49 51 avatarMime: row.avatar_mime, 50 52 bskyClientId: getBskyClient(row.bsky_client_id).id, 53 + bskyButtonVisible: row.bsky_button_visible !== 0, 51 54 accountType: normalizeAccountType(row.account_type), 52 55 createdAt: Number(row.created_at), 53 56 updatedAt: Number(row.updated_at), ··· 59 62 const r = await c.execute({ 60 63 sql: ` 61 64 SELECT did, handle, display_name, avatar_cid, avatar_mime, 62 - bio, bsky_client_id, account_type, created_at, updated_at 65 + bio, bsky_client_id, bsky_button_visible, account_type, 66 + created_at, updated_at 63 67 FROM app_user 64 68 WHERE did = ? 65 69 LIMIT 1 ··· 78 82 const r = await c.execute({ 79 83 sql: ` 80 84 SELECT did, handle, display_name, avatar_cid, avatar_mime, 81 - bio, bsky_client_id, account_type, created_at, updated_at 85 + bio, bsky_client_id, bsky_button_visible, account_type, 86 + created_at, updated_at 82 87 FROM app_user 83 88 WHERE lower(handle) = lower(?) 84 89 LIMIT 1 ··· 174 179 export async function updateAppUserBskyClient( 175 180 did: string, 176 181 bskyClientId: string, 182 + visible = true, 177 183 ): Promise<void> { 178 184 const client = getBskyClient(bskyClientId); 179 185 await withDb(async (c) => { ··· 181 187 sql: ` 182 188 UPDATE app_user SET 183 189 bsky_client_id = ?, 190 + bsky_button_visible = ?, 184 191 updated_at = ? 185 192 WHERE did = ? AND account_type = 'user' 186 193 `, 187 - args: [client.id, Date.now(), did], 194 + args: [client.id, visible ? 1 : 0, Date.now(), did], 188 195 }); 189 196 }); 190 197 }
+7
lib/db.ts
··· 189 189 avatar_cid TEXT, 190 190 avatar_mime TEXT, 191 191 bsky_client_id TEXT NOT NULL DEFAULT 'bluesky', 192 + bsky_button_visible INTEGER NOT NULL DEFAULT 1, 192 193 account_type TEXT NOT NULL, 193 194 created_at INTEGER NOT NULL, 194 195 updated_at INTEGER NOT NULL ··· 491 492 column: "bsky_client_id", 492 493 ddl: 493 494 "ALTER TABLE app_user ADD COLUMN bsky_client_id TEXT NOT NULL DEFAULT 'bluesky'", 495 + }, 496 + { 497 + table: "app_user", 498 + column: "bsky_button_visible", 499 + ddl: 500 + "ALTER TABLE app_user ADD COLUMN bsky_button_visible INTEGER NOT NULL DEFAULT 1", 494 501 }, 495 502 ]; 496 503 for (const m of additiveColumns) {
+6
routes/account/reviews.tsx
··· 127 127 </div> 128 128 <UserBskyClientPicker 129 129 selectedClientId={profile?.bskyClientId ?? null} 130 + visible={profile?.bskyButtonVisible ?? true} 130 131 label={copy.clientLabel} 132 + displayLabel={copy.displayBskyButton} 133 + settingsLabel={copy.configureBskyClient} 131 134 saveLabel={copy.saveClient} 135 + cancelLabel={copy.cancel} 136 + doneLabel={copy.done} 132 137 /> 133 138 </section> 134 139 140 + <h2 class="user-reviews-heading">{copy.reviewsHeading}</h2> 135 141 {reviews.length === 0 136 142 ? ( 137 143 <div class="glass account-reviews-empty">
+2 -1
routes/api/account/profile.ts
··· 23 23 24 24 const form = await ctx.req.formData().catch(() => null); 25 25 const rawClient = form?.get("bskyClientId"); 26 + const visible = form?.getAll("bskyButtonVisible").includes("1"); 26 27 if ( 27 28 typeof rawClient !== "string" || 28 29 !BSKY_CLIENT_IDS.includes(rawClient as typeof BSKY_CLIENT_IDS[number]) ··· 30 31 return new Response("invalid Bluesky client", { status: 400 }); 31 32 } 32 33 33 - await updateAppUserBskyClient(user.did, rawClient); 34 + await updateAppUserBskyClient(user.did, rawClient, visible); 34 35 return new Response(null, { 35 36 status: 303, 36 37 headers: { location: "/account/reviews" },
+11 -9
routes/explore/[handle].tsx
··· 138 138 <ProfileHero profile={profile} /> 139 139 </div> 140 140 <ProfileScreenshots profile={profile} /> 141 - <ProfileWhatsNew 142 - updates={updates} 143 - copy={{ 144 - heading: t.detail.whatsNew.heading, 145 - empty: t.detail.whatsNew.empty, 146 - versionHistory: t.detail.whatsNew.versionHistory, 147 - viewCommit: t.detail.whatsNew.viewCommit, 148 - }} 149 - /> 150 141 151 142 <div class="profile-reviews-shell"> 152 143 <ProfileRatingSummary ··· 211 202 }} 212 203 /> 213 204 </div> 205 + 206 + <ProfileWhatsNew 207 + updates={updates} 208 + copy={{ 209 + heading: t.detail.whatsNew.heading, 210 + empty: t.detail.whatsNew.empty, 211 + versionHistory: t.detail.whatsNew.versionHistory, 212 + viewCommit: t.detail.whatsNew.viewCommit, 213 + readFullUpdate: t.detail.whatsNew.readFullUpdate, 214 + }} 215 + /> 214 216 215 217 {isOwner && ( 216 218 <p style={{ marginTop: "1.5rem" }}>
+28 -24
routes/users/[handle].tsx
··· 26 26 account={buildAccountMenuProps(ctx.state)} 27 27 profile={profile} 28 28 bskyClientId={appUser?.bskyClientId ?? null} 29 + bskyButtonVisible={appUser?.bskyButtonVisible ?? true} 29 30 t={getMessages(ctx.state.locale)} 30 31 />, 31 32 { status: profile ? 200 : 404 }, ··· 37 38 account: ReturnType<typeof buildAccountMenuProps>; 38 39 profile: Awaited<ReturnType<typeof getProfileByHandle>>; 39 40 bskyClientId: string | null; 41 + bskyButtonVisible: boolean; 40 42 // deno-lint-ignore no-explicit-any 41 43 t: any; 42 44 } 43 45 44 46 function UserProfilePage( 45 - { account, profile, bskyClientId, t }: UserProfilePageProps, 47 + { account, profile, bskyClientId, bskyButtonVisible, t }: 48 + UserProfilePageProps, 46 49 ) { 47 50 const copy = t.userProfile; 48 51 if (!profile) { ··· 81 84 </a> 82 85 </p> 83 86 <div class="glass user-public-card"> 84 - <div class="user-public-avatar"> 85 - {avatarUrl 86 - ? <img src={avatarUrl} alt="" decoding="async" /> 87 - : <span>{displayName.slice(0, 1).toUpperCase()}</span>} 87 + <div class="user-public-media"> 88 + <div class="user-public-avatar"> 89 + {avatarUrl 90 + ? <img src={avatarUrl} alt="" decoding="async" /> 91 + : <span>{displayName.slice(0, 1).toUpperCase()}</span>} 92 + </div> 93 + {bskyButtonVisible && ( 94 + <a 95 + class="profile-action profile-action--compact user-public-client-link" 96 + href={client.profileUrl(profile.handle)} 97 + target="_blank" 98 + rel="noopener noreferrer" 99 + aria-label={copy.openIn(client.name)} 100 + title={copy.openIn(client.name)} 101 + > 102 + <img 103 + src={client.iconUrl} 104 + alt="" 105 + class="profile-action-icon" 106 + loading="lazy" 107 + decoding="async" 108 + /> 109 + </a> 110 + )} 88 111 </div> 89 112 <div class="user-public-body"> 90 113 <h1 class="text-section">{displayName}</h1> ··· 94 117 {profile.description} 95 118 </p> 96 119 )} 97 - <a 98 - class="profile-hero-action user-public-client-link" 99 - href={client.profileUrl(profile.handle)} 100 - target="_blank" 101 - rel="noopener noreferrer" 102 - > 103 - <span class="profile-hero-action-icon"> 104 - <img 105 - src={client.iconUrl} 106 - alt="" 107 - loading="lazy" 108 - decoding="async" 109 - /> 110 - </span> 111 - <span>{copy.openIn(client.name)}</span> 112 - <span class="profile-hero-action-arrow" aria-hidden="true"> 113 - 114 - </span> 115 - </a> 116 120 </div> 117 121 </div> 118 122 </div>