a a vibe-coded abomination experiment of a fragrance review platform built on the atmosphere. drydown.social

Merge pull request #7 from taurean/feat/settings-page

Feat/settings page

authored by taurean.bryant.land and committed by

GitHub bf740cf4 edfe8aec

+1748 -121
+9
CLAUDE.md
··· 36 36 npx tsc -b # Run TypeScript compiler in build mode 37 37 ``` 38 38 39 + ### Lexicon Publishing 40 + ```bash 41 + /publish-lexicons # Validate and lint lexicons (default) 42 + /publish-lexicons update # Validate, regenerate types, and fix bugs 43 + /publish-lexicons publish # Full flow including publishing to lexicon.garden 44 + ``` 45 + 46 + See [`LEXICON_PUBLISHING.md`](/LEXICON_PUBLISHING.md) for complete documentation on publishing custom AT Protocol lexicons to the lexicon.garden registry. 47 + 39 48 ## ⚠️ Code Generator Issues (CRITICAL) 40 49 41 50 The `npm run gen-api` command generates TypeScript types from lexicon schemas but has **two critical bugs** that will break the build:
+247
LEXICON_PUBLISHING.md
··· 1 + # Publishing Lexicons to Lexicon.garden 2 + 3 + This guide documents the complete process for validating, publishing, and updating AT Protocol lexicons for the Drydown app to the [Lexicon.garden](https://lexicon.garden) registry. 4 + 5 + ## Overview 6 + 7 + Drydown uses four custom AT Protocol lexicons: 8 + - `social.drydown.house` - Fragrance houses/brands 9 + - `social.drydown.fragrance` - Individual fragrances 10 + - `social.drydown.review` - Three-stage fragrance reviews 11 + - `social.drydown.settings` - User preferences for scoring 12 + 13 + ## Prerequisites 14 + 15 + - **goat CLI tool** installed (`brew install bluesky-social/tap/goat` on macOS) 16 + - **Active Bluesky account** with your PDS 17 + - **DNS access** for `drydown.social` domain 18 + - **Node.js** for TypeScript type generation 19 + 20 + ## Step-by-Step Publishing Process 21 + 22 + ### 1. Validate Lexicon Schemas 23 + 24 + Parse all lexicon files to ensure they're valid: 25 + 26 + ```bash 27 + goat lex parse src/lexicons/social.drydown.house.json 28 + goat lex parse src/lexicons/social.drydown.fragrance.json 29 + goat lex parse src/lexicons/social.drydown.review.json 30 + goat lex parse src/lexicons/social.drydown.settings.json 31 + ``` 32 + 33 + **Expected output**: `success` for each file 34 + 35 + ### 2. Lint for Best Practices 36 + 37 + Check schemas for quality issues: 38 + 39 + ```bash 40 + goat lex lint src/lexicons/*.json 41 + ``` 42 + 43 + **Expected output**: 🟢 green checkmark for all files 44 + 45 + **Common issues to fix:** 46 + - Invalid record key specifiers: Use `"literal:self"` instead of `"self"` 47 + - Unlimited strings: Add `maxLength` to string fields 48 + - Missing descriptions: Add clear descriptions to all fields 49 + 50 + ### 3. Regenerate TypeScript Types 51 + 52 + After any lexicon changes, regenerate the TypeScript client: 53 + 54 + ```bash 55 + npm run gen-api 56 + ``` 57 + 58 + Type `y` when prompted to confirm. 59 + 60 + ### 4. Fix Generator Bugs 61 + 62 + The `@atproto/lex-cli` generator has known bugs that must be fixed manually after each run. 63 + 64 + **Issue 1: Remove unused imports from type files** 65 + 66 + In `src/client/types/social/drydown/*.ts` files, change: 67 + 68 + ```typescript 69 + // BEFORE (generated, broken) 70 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 71 + import { CID } from 'multiformats/cid' 72 + import { validate as _validate } from '../../../lexicons' 73 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 74 + 75 + // AFTER (fixed) 76 + import { validate as _validate } from '../../../lexicons' 77 + import { is$typed as _is$typed } from '../../../util' 78 + ``` 79 + 80 + **Issue 2: Fix `src/client/index.ts`** 81 + 82 + Add AT Protocol imports and restore bsky schemas: 83 + 84 + ```typescript 85 + // Add these imports at the top 86 + import { 87 + ComAtprotoRepoListRecords, 88 + ComAtprotoRepoGetRecord, 89 + ComAtprotoRepoCreateRecord, 90 + ComAtprotoRepoPutRecord, 91 + ComAtprotoRepoDeleteRecord, 92 + } from '@atproto/api' 93 + import { schemas as bskySchemas } from '@atproto/api' 94 + 95 + // Remove this import 96 + // import { CID } from 'multiformats/cid' 97 + 98 + // Fix the constructor 99 + constructor(options: FetchHandler | FetchHandlerOptions) { 100 + super(options, [...schemas, ...bskySchemas]) // NOT just `schemas` 101 + this.social = new SocialNS(this) 102 + } 103 + ``` 104 + 105 + **Issue 3: Fix `src/client/lexicons.ts`** 106 + 107 + Remove unused import: 108 + 109 + ```typescript 110 + // BEFORE 111 + import { type $Typed, is$typed, maybe$typed } from './util.js' 112 + 113 + // AFTER 114 + import { is$typed, maybe$typed } from './util.js' 115 + ``` 116 + 117 + ### 5. Verify Build 118 + 119 + Ensure TypeScript compilation succeeds: 120 + 121 + ```bash 122 + npx tsc -b 123 + npm run build 124 + ``` 125 + 126 + Both commands should complete without errors. 127 + 128 + ### 6. Login to goat (First Time Only) 129 + 130 + ```bash 131 + goat account login 132 + ``` 133 + 134 + Enter your Bluesky handle (e.g., `username.bsky.social`) and app password. 135 + 136 + **To create an app password:** 137 + 1. Go to Settings → App Passwords in Bluesky 138 + 2. Create a new app password 139 + 3. Copy and paste when prompted 140 + 141 + ### 7. Get Your DID 142 + 143 + ```bash 144 + goat account status 145 + ``` 146 + 147 + Copy the DID value (starts with `did:plc:...`) 148 + 149 + ### 8. Configure DNS Authority 150 + 151 + To prove you control the `drydown.social` domain, add a DNS TXT record: 152 + 153 + **DNS Record Details:** 154 + - **Name/Host**: `_lexicon.drydown.social` (or just `_lexicon` depending on your DNS provider) 155 + - **Type**: `TXT` 156 + - **Value**: `did=YOUR_DID_HERE` (replace with your actual DID from step 7) 157 + - **TTL**: 3600 (or default) 158 + 159 + **Example:** 160 + ``` 161 + _lexicon.drydown.social. IN TXT "did=did:plc:abc123xyz..." 162 + ``` 163 + 164 + ### 9. Verify DNS Propagation 165 + 166 + Wait 5-10 minutes after adding the DNS record, then verify: 167 + 168 + ```bash 169 + dig TXT _lexicon.drydown.social 170 + ``` 171 + 172 + **Expected output:** Should show your DID in the TXT record 173 + 174 + Alternatively, use an online DNS checker like https://dnschecker.org 175 + 176 + ### 10. Publish Lexicons 177 + 178 + Once DNS is configured and verified: 179 + 180 + ```bash 181 + goat lex publish src/lexicons 182 + ``` 183 + 184 + This command will: 185 + 1. Validate all schemas 186 + 2. Check DNS authority records 187 + 3. Create lexicon records in your AT Protocol repository 188 + 4. Register them with lexicon.garden 189 + 190 + **Expected output:** Success messages for each published lexicon 191 + 192 + ### 11. Verify on Lexicon.garden 193 + 194 + Visit https://lexicon.garden and search for `social.drydown` 195 + 196 + You should see all four lexicons listed with: 197 + - ✅ Authority badge (DNS verification successful) 198 + - Full documentation 199 + - Relationship graph showing connections between lexicons 200 + 201 + ## Updating Existing Lexicons 202 + 203 + To update lexicons after they've been published: 204 + 205 + 1. **Make changes** to the JSON schema files in `src/lexicons/` 206 + 2. **Validate** with `goat lex parse` 207 + 3. **Lint** with `goat lex lint` 208 + 4. **Check for breaking changes**: `goat lex breaking src/lexicons` 209 + 5. **Regenerate types** with `npm run gen-api` 210 + 6. **Fix generator bugs** (see step 4 above) 211 + 7. **Verify build** with `npx tsc -b` 212 + 8. **Publish updates**: `goat lex publish src/lexicons` 213 + 214 + ## Common Issues 215 + 216 + ### "error: expected NSID, got empty string" 217 + 218 + This usually means the command syntax is wrong. Use `goat lex parse <file>` not `goat lex validate`. 219 + 220 + ### "DNS authority check failed" 221 + 222 + - Wait longer for DNS propagation (can take up to 24 hours) 223 + - Verify the TXT record is correctly formatted 224 + - Ensure you're using the correct DID 225 + - Check that the record name is exactly `_lexicon.drydown.social` 226 + 227 + ### "invalid record key specifier: self" 228 + 229 + Use `"key": "literal:self"` instead of `"key": "self"` in your lexicon schema. 230 + 231 + ### TypeScript build fails after regenerating types 232 + 233 + Apply the generator bug fixes in step 4. These must be done every time you run `npm run gen-api`. 234 + 235 + ## Additional Resources 236 + 237 + - [AT Protocol Lexicon Specification](https://atproto.com/specs/lexicon) 238 + - [Lexicon.garden Help](https://lexicon.garden/help) 239 + - [goat CLI Documentation](https://github.com/bluesky-social/indigo/tree/main/cmd/goat) 240 + - [Project CLAUDE.md](./CLAUDE.md) - See "Code Generator Issues" section 241 + 242 + ## Notes 243 + 244 + - Lexicon.garden can take up to 24 hours to fully index new schemas due to DNS caching, identity resolution, and indexing delays 245 + - Always test lexicon changes in a development environment before publishing 246 + - Breaking changes to lexicons (removing fields, changing types) can break existing applications 247 + - Keep lexicon files under version control and document all changes
+1 -1
public/client-metadata.json
··· 8 8 "redirect_uris": [ 9 9 "https://drydown.social/" 10 10 ], 11 - "scope": "atproto repo:social.drydown.house repo:social.drydown.fragrance repo:social.drydown.review rpc:app.bsky.actor.getProfile?aud=* rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.getTimeline?aud=* rpc:app.bsky.feed.post?aud=*", 11 + "scope": "atproto repo:social.drydown.house repo:social.drydown.fragrance repo:social.drydown.review repo:social.drydown.settings rpc:app.bsky.actor.getProfile?aud=* rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.getTimeline?aud=* rpc:app.bsky.feed.post?aud=*", 12 12 "grant_types": [ 13 13 "authorization_code", 14 14 "refresh_token"
+270 -16
src/app.css
··· 5 5 --text-color: #000000; 6 6 --text-secondary: #666666; 7 7 --border-color: #dddddd; 8 - 8 + 9 9 --card-border: #dddddd; 10 10 --card-border-dashed: #cccccc; 11 - 11 + 12 12 --card-bg: #f5f5f5; 13 13 --card-bg-interactive: #ffffff; 14 14 --card-bg-hover: #f0f8ff; 15 - 15 + 16 16 --status-ready: #0066cc; 17 17 --status-completed: #00aa00; 18 18 --status-inprogress: #666666; 19 - 19 + 20 20 --star-color: #ff9900; 21 + 22 + --site-max-width: 720px; 21 23 } 22 24 23 25 @media (prefers-color-scheme: dark) { ··· 51 53 52 54 #app { 53 55 width: 100%; 54 - max-width: 600px; /* Enforce global max-width */ 56 + max-width: var(--site-max-width); 55 57 margin: 0 auto; 56 58 padding: 0; 57 - text-align: left; /* Default alignment */ 59 + text-align: left; 58 60 } 59 61 60 62 /* Page container can just control padding/box-sizing now */ ··· 74 76 padding: 2em; 75 77 } 76 78 77 - header { 79 + /* Site Header */ 80 + .site-header { 78 81 display: flex; 79 82 justify-content: space-between; 80 83 align-items: center; ··· 83 86 width: 100%; 84 87 } 85 88 86 - header h1 { 87 - margin: 0; 88 - font-size: 2.2rem; 89 + .site-logo { 90 + font-size: 1.5rem; 91 + font-weight: 800; 89 92 letter-spacing: -0.02em; 90 - font-weight: 800; 91 93 color: var(--text-color); 94 + text-decoration: none; 92 95 } 93 96 94 - header .user-info { 97 + .site-nav { 95 98 display: flex; 96 99 gap: 1rem; 97 100 align-items: center; 101 + } 102 + 103 + .nav-link { 98 104 font-size: 0.9rem; 99 105 color: var(--text-secondary); 106 + text-decoration: none; 107 + transition: color 0.15s ease; 100 108 } 101 109 102 - header .user-info .btn-primary { 110 + .nav-link:hover { 111 + color: var(--text-color); 112 + } 113 + 114 + .nav-link-username { 115 + color: var(--text-color); 116 + } 117 + 118 + .btn-small { 103 119 font-size: 0.85rem; 104 120 padding: 0.5rem 0.75rem; 121 + } 122 + 123 + /* Legacy header styles for page-specific headers */ 124 + header { 125 + display: flex; 126 + justify-content: space-between; 127 + align-items: center; 128 + margin-bottom: 1.5rem; 129 + padding: 0; 130 + width: 100%; 131 + } 132 + 133 + header h1 { 134 + margin: 0; 135 + font-size: 2.2rem; 136 + letter-spacing: -0.02em; 137 + font-weight: 800; 138 + color: var(--text-color); 105 139 } 106 140 107 141 /* Profile Avatar */ ··· 587 621 588 622 /* Login Form Styles */ 589 623 .login-form-card { 590 - max-width: 560px; 624 + max-width: var(--site-max-width); 591 625 margin: 0 auto; 592 626 padding: 2rem 2rem 1.5rem; 593 627 border: 1px solid var(--border-color); ··· 610 644 display: flex; 611 645 flex-direction: column; 612 646 gap: 1rem; 613 - max-width: 560px; 647 + max-width: var(--site-max-width); 614 648 margin: 0 auto; 615 649 } 616 650 ··· 760 794 } 761 795 762 796 .app-disclaimer.detailed { 763 - max-width: 500px; 797 + max-width: var(--site-max-width); 764 798 margin-left: auto; 765 799 margin-right: auto; 766 800 } ··· 922 956 .rubric-display-value.full { 923 957 max-width: 300px; 924 958 } 959 + 960 + /* Settings Page Styles */ 961 + .settings-page { 962 + width: 100%; 963 + } 964 + 965 + .settings-header { 966 + margin-bottom: 2rem; 967 + } 968 + 969 + .settings-header h1 { 970 + font-size: 2rem; 971 + font-weight: 800; 972 + letter-spacing: -0.02em; 973 + margin: 0 0 0.75rem 0; 974 + color: var(--text-color); 975 + } 976 + 977 + .settings-intro { 978 + font-size: 0.8rem; 979 + color: var(--text-secondary); 980 + line-height: 1.5; 981 + margin: 1rem 0 0 0; 982 + width: 54ch; 983 + padding-inline-start: 4em; 984 + } 985 + 986 + .settings-preferences { 987 + display: flex; 988 + flex-direction: column; 989 + gap: 2.5rem; 990 + margin-bottom: 2rem; 991 + } 992 + 993 + .settings-section { 994 + margin-bottom: 2rem; 995 + padding-top: 1.5rem; 996 + border-top: 1px solid var(--border-color); 997 + } 998 + 999 + .settings-section-title { 1000 + font-size: 1.25rem; 1001 + font-weight: 600; 1002 + margin: 0 0 0.5rem 0; 1003 + color: var(--text-color); 1004 + } 1005 + 1006 + .settings-section-description { 1007 + font-size: 0.9rem; 1008 + color: var(--text-secondary); 1009 + margin: 0 0 1rem 0; 1010 + line-height: 1.5; 1011 + } 1012 + 1013 + .settings-actions { 1014 + display: flex; 1015 + gap: 1rem; 1016 + justify-content: flex-end; 1017 + padding-top: 1.5rem; 1018 + border-top: 1px solid var(--border-color); 1019 + } 1020 + 1021 + /* Preference Selector Styles */ 1022 + .preference-selector { 1023 + margin-bottom: 0; 1024 + } 1025 + 1026 + .preference-header { 1027 + margin-bottom: 1rem; 1028 + } 1029 + 1030 + .preference-question { 1031 + font-size: 1.1rem; 1032 + font-weight: 600; 1033 + margin-bottom: 0.25rem; 1034 + color: var(--text-color); 1035 + } 1036 + 1037 + .preference-description { 1038 + font-size: 0.85rem; 1039 + color: var(--text-secondary); 1040 + line-height: 1.4; 1041 + } 1042 + 1043 + .preference-options { 1044 + display: flex; 1045 + flex-direction: column; 1046 + gap: 0.5rem; 1047 + } 1048 + 1049 + .preference-option { 1050 + display: flex; 1051 + align-items: center; 1052 + padding: 0.875rem 1rem; 1053 + border: 2px solid var(--card-border); 1054 + border-radius: 8px; 1055 + background: var(--card-bg); 1056 + cursor: pointer; 1057 + transition: border-color 0.15s ease, background-color 0.15s ease; 1058 + text-align: left; 1059 + width: 100%; 1060 + font-size: inherit; 1061 + font-family: inherit; 1062 + color: inherit; 1063 + } 1064 + 1065 + .preference-option:hover:not(.disabled) { 1066 + border-color: var(--status-ready); 1067 + background: var(--card-bg-hover); 1068 + } 1069 + 1070 + .preference-option.selected { 1071 + border-color: var(--status-ready); 1072 + background: var(--card-bg-hover); 1073 + } 1074 + 1075 + .preference-option:focus-visible { 1076 + outline: 2px solid var(--status-ready); 1077 + outline-offset: 2px; 1078 + } 1079 + 1080 + .preference-option.disabled { 1081 + opacity: 0.5; 1082 + cursor: not-allowed; 1083 + } 1084 + 1085 + .preference-option-text { 1086 + line-height: 1.4; 1087 + color: var(--text-color); 1088 + } 1089 + 1090 + /* Score Lens Toggle */ 1091 + .score-lens-options { 1092 + display: flex; 1093 + flex-direction: column; 1094 + gap: 0.5rem; 1095 + } 1096 + 1097 + .score-lens-option { 1098 + display: flex; 1099 + flex-direction: column; 1100 + align-items: flex-start; 1101 + padding: 1rem; 1102 + border: 2px solid var(--card-border); 1103 + border-radius: 8px; 1104 + background: var(--card-bg); 1105 + cursor: pointer; 1106 + transition: border-color 0.15s ease, background-color 0.15s ease; 1107 + text-align: left; 1108 + width: 100%; 1109 + font-size: inherit; 1110 + font-family: inherit; 1111 + color: inherit; 1112 + } 1113 + 1114 + .score-lens-option:hover:not(:disabled) { 1115 + border-color: var(--status-ready); 1116 + background: var(--card-bg-hover); 1117 + } 1118 + 1119 + .score-lens-option.selected { 1120 + border-color: var(--status-ready); 1121 + background: var(--card-bg-hover); 1122 + } 1123 + 1124 + .score-lens-option:focus-visible { 1125 + outline: 2px solid var(--status-ready); 1126 + outline-offset: 2px; 1127 + } 1128 + 1129 + .score-lens-option:disabled { 1130 + opacity: 0.5; 1131 + cursor: not-allowed; 1132 + } 1133 + 1134 + .score-lens-label { 1135 + font-weight: 600; 1136 + color: var(--text-color); 1137 + margin-bottom: 0.25rem; 1138 + } 1139 + 1140 + .score-lens-description { 1141 + font-size: 0.85rem; 1142 + color: var(--text-secondary); 1143 + line-height: 1.4; 1144 + } 1145 + 1146 + /* Success Message */ 1147 + .success-message { 1148 + background: #d4edda; 1149 + color: #155724; 1150 + padding: 0.75rem 1rem; 1151 + border-radius: 8px; 1152 + margin-bottom: 1.5rem; 1153 + font-size: 0.9rem; 1154 + } 1155 + 1156 + @media (prefers-color-scheme: dark) { 1157 + .success-message { 1158 + background: #1e3a1e; 1159 + color: #75d675; 1160 + } 1161 + } 1162 + 1163 + /* Touch targets for preference options */ 1164 + @media (pointer: coarse) { 1165 + .preference-option, 1166 + .score-lens-option { 1167 + min-height: 56px; 1168 + padding: 1rem; 1169 + } 1170 + } 1171 + 1172 + /* Personalized score indicator */ 1173 + .personalized-score-indicator { 1174 + font-size: 0.75rem; 1175 + color: var(--text-secondary); 1176 + margin-top: 0.25rem; 1177 + font-style: italic; 1178 + }
+13 -23
src/app.tsx
··· 11 11 import { HousePage } from './components/HousePage' 12 12 import { EditHousePage } from './components/EditHousePage' 13 13 import { ProfileHousesPage } from './components/ProfileHousesPage' 14 + import { SettingsPage } from './components/SettingsPage' 15 + import { Header } from './components/Header' 14 16 import { Footer } from './components/Footer' 15 17 import type { OAuthSession } from '@atproto/oauth-client-browser' 16 18 import { AtpBaseClient } from './client/index' 17 - import { Route, Switch, Link } from 'wouter' 19 + import { Route, Switch } from 'wouter' 18 20 import { cache, TTL } from './services/cache' 19 21 20 22 interface HomeProps { ··· 57 59 <LandingPage /> 58 60 ) : ( 59 61 <> 60 - <header> 61 - <Link href="/" onClick={handleBackToDashboard} style={{ textDecoration: 'none', color: 'inherit' }}> 62 - <h1>Drydown</h1> 63 - </Link> 64 - <div class="user-info"> 65 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8 }}> 66 - Explore 67 - </Link> 68 - <Link href={`/profile/${userProfile?.handle || session.sub}/reviews`} style={{ textDecoration: 'none', color: 'inherit' }}> 69 - {userProfile?.displayName || userProfile?.handle || session.sub} 70 - </Link> 71 - <button onClick={onLogout} class="btn-primary"> 72 - sign out 73 - </button> 74 - </div> 75 - </header> 62 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 76 63 77 64 <div class="card"> 78 65 {view === 'home' ? ( ··· 202 189 {() => <Home session={session} userProfile={userProfile} onLogout={handleLogout} />} 203 190 </Route> 204 191 <Route path="/explore"> 205 - {() => <ExplorePage />} 192 + {() => <ExplorePage session={session} userProfile={userProfile} onLogout={handleLogout} />} 193 + </Route> 194 + <Route path="/preferences"> 195 + {() => <SettingsPage session={session} userProfile={userProfile} onLogout={handleLogout} />} 206 196 </Route> 207 197 <Route path="/profile/:handle/reviews"> 208 - {(params) => <ProfilePage handle={params.handle} />} 198 + {(params) => <ProfilePage handle={params.handle} session={session} userProfile={userProfile} onLogout={handleLogout} />} 209 199 </Route> 210 200 <Route path="/profile/:handle/houses"> 211 - {(params) => <ProfileHousesPage handle={params.handle} />} 201 + {(params) => <ProfileHousesPage handle={params.handle} session={session} userProfile={userProfile} onLogout={handleLogout} />} 212 202 </Route> 213 203 <Route path="/profile/:handle/review/:rkey"> 214 - {(params) => <SingleReviewPage handle={params.handle} rkey={params.rkey} session={session} />} 204 + {(params) => <SingleReviewPage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 215 205 </Route> 216 206 <Route path="/profile/:handle/house/:rkey"> 217 - {(params) => <HousePage handle={params.handle} rkey={params.rkey} session={session} />} 207 + {(params) => <HousePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 218 208 </Route> 219 209 <Route path="/profile/:handle/house/:rkey/edit"> 220 - {(params) => <EditHousePage handle={params.handle} rkey={params.rkey} session={session} />} 210 + {(params) => <EditHousePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 221 211 </Route> 222 212 {/* Fallback to Home for now, or 404 */} 223 213 <Route path="/:rest*">
+95 -4
src/client/index.ts
··· 6 6 type FetchHandler, 7 7 type FetchHandlerOptions, 8 8 } from '@atproto/xrpc' 9 - import { schemas } from './lexicons.js' 10 - import { type OmitKey, type Un$Typed } from './util.js' 11 9 import { 12 10 ComAtprotoRepoListRecords, 13 11 ComAtprotoRepoGetRecord, ··· 15 13 ComAtprotoRepoPutRecord, 16 14 ComAtprotoRepoDeleteRecord, 17 15 } from '@atproto/api' 16 + import { schemas as bskySchemas } from '@atproto/api' 17 + import { schemas } from './lexicons.js' 18 + import { type OmitKey, type Un$Typed } from './util.js' 18 19 import * as SocialDrydownFragrance from './types/social/drydown/fragrance.js' 19 20 import * as SocialDrydownHouse from './types/social/drydown/house.js' 20 21 import * as SocialDrydownReview from './types/social/drydown/review.js' 22 + import * as SocialDrydownSettings from './types/social/drydown/settings.js' 21 23 22 24 export * as SocialDrydownFragrance from './types/social/drydown/fragrance.js' 23 25 export * as SocialDrydownHouse from './types/social/drydown/house.js' 24 26 export * as SocialDrydownReview from './types/social/drydown/review.js' 25 - 26 - import { schemas as bskySchemas } from '@atproto/api' 27 + export * as SocialDrydownSettings from './types/social/drydown/settings.js' 27 28 28 29 export class AtpBaseClient extends XrpcClient { 29 30 social: SocialNS ··· 54 55 fragrance: SocialDrydownFragranceRecord 55 56 house: SocialDrydownHouseRecord 56 57 review: SocialDrydownReviewRecord 58 + settings: SocialDrydownSettingsRecord 57 59 58 60 constructor(client: XrpcClient) { 59 61 this._client = client 60 62 this.fragrance = new SocialDrydownFragranceRecord(client) 61 63 this.house = new SocialDrydownHouseRecord(client) 62 64 this.review = new SocialDrydownReviewRecord(client) 65 + this.settings = new SocialDrydownSettingsRecord(client) 63 66 } 64 67 } 65 68 ··· 303 306 ) 304 307 } 305 308 } 309 + 310 + export class SocialDrydownSettingsRecord { 311 + _client: XrpcClient 312 + 313 + constructor(client: XrpcClient) { 314 + this._client = client 315 + } 316 + 317 + async list( 318 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 319 + ): Promise<{ 320 + cursor?: string 321 + records: { uri: string; value: SocialDrydownSettings.Record }[] 322 + }> { 323 + const res = await this._client.call('com.atproto.repo.listRecords', { 324 + collection: 'social.drydown.settings', 325 + ...params, 326 + }) 327 + return res.data 328 + } 329 + 330 + async get( 331 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 332 + ): Promise<{ 333 + uri: string 334 + cid: string 335 + value: SocialDrydownSettings.Record 336 + }> { 337 + const res = await this._client.call('com.atproto.repo.getRecord', { 338 + collection: 'social.drydown.settings', 339 + ...params, 340 + }) 341 + return res.data 342 + } 343 + 344 + async create( 345 + params: OmitKey< 346 + ComAtprotoRepoCreateRecord.InputSchema, 347 + 'collection' | 'record' 348 + >, 349 + record: Un$Typed<SocialDrydownSettings.Record>, 350 + headers?: Record<string, string>, 351 + ): Promise<{ uri: string; cid: string }> { 352 + const collection = 'social.drydown.settings' 353 + const res = await this._client.call( 354 + 'com.atproto.repo.createRecord', 355 + undefined, 356 + { 357 + collection, 358 + rkey: 'self', 359 + ...params, 360 + record: { ...record, $type: collection }, 361 + }, 362 + { encoding: 'application/json', headers }, 363 + ) 364 + return res.data 365 + } 366 + 367 + async put( 368 + params: OmitKey< 369 + ComAtprotoRepoPutRecord.InputSchema, 370 + 'collection' | 'record' 371 + >, 372 + record: Un$Typed<SocialDrydownSettings.Record>, 373 + headers?: Record<string, string>, 374 + ): Promise<{ uri: string; cid: string }> { 375 + const collection = 'social.drydown.settings' 376 + const res = await this._client.call( 377 + 'com.atproto.repo.putRecord', 378 + undefined, 379 + { collection, ...params, record: { ...record, $type: collection } }, 380 + { encoding: 'application/json', headers }, 381 + ) 382 + return res.data 383 + } 384 + 385 + async delete( 386 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 387 + headers?: Record<string, string>, 388 + ): Promise<void> { 389 + await this._client.call( 390 + 'com.atproto.repo.deleteRecord', 391 + undefined, 392 + { collection: 'social.drydown.settings', ...params }, 393 + { headers }, 394 + ) 395 + } 396 + }
+63
src/client/lexicons.ts
··· 216 216 }, 217 217 }, 218 218 }, 219 + SocialDrydownSettings: { 220 + lexicon: 1, 221 + id: 'social.drydown.settings', 222 + defs: { 223 + main: { 224 + type: 'record', 225 + description: 'User preferences for fragrance review scoring', 226 + key: 'literal:self', 227 + record: { 228 + type: 'object', 229 + required: ['createdAt'], 230 + properties: { 231 + presenceStyle: { 232 + type: 'integer', 233 + minimum: 1, 234 + maximum: 5, 235 + description: 236 + 'How the user prefers to be noticed (1=skin scent, 5=bold presence)', 237 + }, 238 + longevityPriority: { 239 + type: 'integer', 240 + minimum: 1, 241 + maximum: 5, 242 + description: 243 + 'How important all-day longevity is (1=not important, 5=essential)', 244 + }, 245 + complexityPreference: { 246 + type: 'integer', 247 + minimum: 1, 248 + maximum: 5, 249 + description: 250 + 'Preference for fragrance complexity (1=simple, 5=intricate)', 251 + }, 252 + scoringApproach: { 253 + type: 'integer', 254 + minimum: 1, 255 + maximum: 5, 256 + description: 257 + 'How user evaluates fragrances (1=instinct, 5=analytical)', 258 + }, 259 + scoreLens: { 260 + type: 'string', 261 + maxLength: 10, 262 + knownValues: ['theirs', 'mine'], 263 + description: 264 + "When viewing others' reviews: show their score or recalculate with your preferences", 265 + }, 266 + createdAt: { 267 + type: 'string', 268 + format: 'datetime', 269 + description: 'Timestamp when settings were first created', 270 + }, 271 + updatedAt: { 272 + type: 'string', 273 + format: 'datetime', 274 + description: 'Timestamp when settings were last updated', 275 + }, 276 + }, 277 + }, 278 + }, 279 + }, 280 + }, 219 281 } as const satisfies Record<string, LexiconDoc> 220 282 export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 221 283 export const lexicons: Lexicons = new Lexicons(schemas) ··· 252 314 SocialDrydownFragrance: 'social.drydown.fragrance', 253 315 SocialDrydownHouse: 'social.drydown.house', 254 316 SocialDrydownReview: 'social.drydown.review', 317 + SocialDrydownSettings: 'social.drydown.settings', 255 318 } as const
+44
src/client/types/social/drydown/settings.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { validate as _validate } from '../../../lexicons' 5 + import { is$typed as _is$typed } from '../../../util' 6 + 7 + const is$typed = _is$typed, 8 + validate = _validate 9 + const id = 'social.drydown.settings' 10 + 11 + export interface Main { 12 + $type: 'social.drydown.settings' 13 + /** How the user prefers to be noticed (1=skin scent, 5=bold presence) */ 14 + presenceStyle?: number 15 + /** How important all-day longevity is (1=not important, 5=essential) */ 16 + longevityPriority?: number 17 + /** Preference for fragrance complexity (1=simple, 5=intricate) */ 18 + complexityPreference?: number 19 + /** How user evaluates fragrances (1=instinct, 5=analytical) */ 20 + scoringApproach?: number 21 + /** When viewing others' reviews: show their score or recalculate with your preferences */ 22 + scoreLens?: 'theirs' | 'mine' | (string & {}) 23 + /** Timestamp when settings were first created */ 24 + createdAt: string 25 + /** Timestamp when settings were last updated */ 26 + updatedAt?: string 27 + [k: string]: unknown 28 + } 29 + 30 + const hashMain = 'main' 31 + 32 + export function isMain<V>(v: V) { 33 + return is$typed(v, id, hashMain) 34 + } 35 + 36 + export function validateMain<V>(v: V) { 37 + return validate<Main & V>(v, id, hashMain, true) 38 + } 39 + 40 + export { 41 + type Main as Record, 42 + isMain as isRecord, 43 + validateMain as validateRecord, 44 + }
+7 -6
src/components/EditHousePage.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 - import { useLocation, Link } from 'wouter' 2 + import { useLocation } from 'wouter' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 4 import { AtpBaseClient } from '../client/index' 4 5 import { AppDisclaimer } from './AppDisclaimer' 6 + import { Header } from './Header' 5 7 import { Footer } from './Footer' 6 - import type { OAuthSession } from '@atproto/oauth-client-browser' 7 8 import { deleteHouse } from '../api/houses' 8 9 import { bulkUpdateFragranceHouse } from '../api/fragrances' 9 10 import { Combobox } from './Combobox' ··· 15 16 handle: string 16 17 rkey: string 17 18 session: OAuthSession | null 19 + userProfile?: { displayName?: string; handle: string } | null 20 + onLogout?: () => void 18 21 } 19 22 20 - export function EditHousePage({ handle, rkey, session }: EditHousePageProps) { 23 + export function EditHousePage({ handle, rkey, session, userProfile, onLogout }: EditHousePageProps) { 21 24 const [, setLocation] = useLocation() 22 25 const [houseName, setHouseName] = useState('') 23 26 const [isLoading, setIsLoading] = useState(true) ··· 216 219 217 220 return ( 218 221 <div className="page-container"> 219 - <nav className="main-nav" style={{ marginBottom: '2rem' }}> 220 - <Link href="/" className="nav-logo">Drydown</Link> 221 - </nav> 222 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 222 223 223 224 <h2>Edit House</h2> 224 225
+17 -10
src/components/ExplorePage.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 - import { Link, useLocation } from 'wouter' 2 + import { useLocation } from 'wouter' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 4 import { SEO } from './SEO' 4 5 import { AppDisclaimer } from './AppDisclaimer' 6 + import { Header } from './Header' 5 7 import { Footer } from './Footer' 6 8 import { ReviewCard, type AuthorInfo } from './ReviewCard' 7 9 import { resolveIdentity } from '../utils/resolveIdentity' 8 10 import { batchResolveAtUris } from '../utils/atUriUtils' 11 + import { useUserPreferences } from '../hooks/useUserPreferences' 9 12 10 13 interface HydratedReview { 11 14 uri: string ··· 15 18 author: AuthorInfo 16 19 } 17 20 21 + interface ExplorePageProps { 22 + session: OAuthSession | null 23 + userProfile?: { displayName?: string; handle: string } | null 24 + onLogout?: () => void 25 + } 26 + 18 27 const MICROCOSM_API = "https://ufos-api.microcosm.blue" 19 28 20 - export function ExplorePage() { 29 + export function ExplorePage({ session, userProfile, onLogout }: ExplorePageProps) { 21 30 const [, setLocation] = useLocation() 22 31 const [reviews, setReviews] = useState<HydratedReview[]>([]) 23 32 const [isLoading, setIsLoading] = useState(true) 24 33 const [error, setError] = useState<string | null>(null) 34 + const { preferences } = useUserPreferences(session) 25 35 26 36 useEffect(() => { 27 37 async function fetchExploreFeed() { ··· 111 121 112 122 return ( 113 123 <div className="explore-page page-container"> 114 - <SEO 124 + <SEO 115 125 title="Explore - Drydown" 116 126 description="See the latest fragrance reviews from the Drydown community in real-time." 117 127 url={window.location.href} 118 128 /> 119 - <nav className="main-nav"> 120 - <Link href="/" className="nav-logo">Drydown</Link> 121 - <div style={{ flex: 1 }} /> 122 - </nav> 129 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 123 130 124 - <header> 125 - <h1>Most Recent Reviews</h1> 126 - </header> 131 + <h1 style={{ marginBottom: '1.5rem' }}>Most Recent Reviews</h1> 127 132 128 133 {isLoading ? ( 129 134 <div>Loading the latest reviews...</div> ··· 147 152 setLocation(`/profile/${review.author.handle}/review/${rkey}`) 148 153 } 149 154 }} 155 + viewerPreferences={preferences || undefined} 156 + viewerDid={session?.sub} 150 157 /> 151 158 ))} 152 159 </div>
+47
src/components/Header.tsx
··· 1 + import { Link } from 'wouter' 2 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 + 4 + interface HeaderProps { 5 + session: OAuthSession | null 6 + userProfile?: { displayName?: string; handle: string } | null 7 + onLogout?: () => void 8 + } 9 + 10 + /** 11 + * Shared header component for consistent navigation across the site. 12 + * 13 + * Signed in: Explore, Settings, Username, Sign Out 14 + * Signed out: Explore 15 + */ 16 + export function Header({ session, userProfile, onLogout }: HeaderProps) { 17 + return ( 18 + <header className="site-header"> 19 + <Link href="/" className="site-logo"> 20 + Drydown 21 + </Link> 22 + <nav className="site-nav"> 23 + <Link href="/explore" className="nav-link"> 24 + Explore 25 + </Link> 26 + {session && ( 27 + <> 28 + <Link href="/preferences" className="nav-link"> 29 + Preferences 30 + </Link> 31 + <Link 32 + href={`/profile/${userProfile?.handle || session.sub}/reviews`} 33 + className="nav-link nav-link-username" 34 + > 35 + {userProfile?.displayName || userProfile?.handle || session.sub} 36 + </Link> 37 + {onLogout && ( 38 + <button onClick={onLogout} className="btn-primary btn-small"> 39 + sign out 40 + </button> 41 + )} 42 + </> 43 + )} 44 + </nav> 45 + </header> 46 + ) 47 + }
+10 -9
src/components/HousePage.tsx
··· 2 2 import { useLocation, Link } from 'wouter' 3 3 import type { OAuthSession } from '@atproto/oauth-client-browser' 4 4 import { SEO } from './SEO' 5 + import { Header } from './Header' 5 6 import { Footer } from './Footer' 6 7 import { ReviewList } from './ReviewList' 7 8 import { resolveIdentity } from '../utils/resolveIdentity' ··· 9 10 import { cache, TTL } from '../services/cache' 10 11 import { calculateWeightedScore, decodeWeightedScore } from '../utils/reviewUtils' 11 12 import type { AuthorInfo } from './ReviewCard' 13 + import { useUserPreferences } from '../hooks/useUserPreferences' 12 14 13 15 const MICROCOSM_API = "https://ufos-api.microcosm.blue" 14 16 15 17 interface HousePageProps { 16 18 handle: string 17 19 rkey: string 18 - session?: OAuthSession | null 20 + session: OAuthSession | null 21 + userProfile?: { displayName?: string; handle: string } | null 22 + onLogout?: () => void 19 23 } 20 24 21 - export function HousePage({ handle, rkey, session }: HousePageProps) { 25 + export function HousePage({ handle, rkey, session, userProfile, onLogout }: HousePageProps) { 22 26 const [, setLocation] = useLocation() 23 27 const [houseName, setHouseName] = useState<string>('Loading House...') 24 28 const [reviews, setReviews] = useState<Array<{ uri: string; value: any }>>([]) ··· 28 32 const [error, setError] = useState<string | null>(null) 29 33 const [manager, setManager] = useState<{ handle: string, displayName?: string, avatar?: string } | null>(null) 30 34 const [houseDid, setHouseDid] = useState<string | null>(null) 35 + const { preferences } = useUserPreferences(session) 31 36 32 37 // Analytics 33 38 const [totalRating, setTotalRating] = useState<number>(0) ··· 195 200 url={window.location.href} 196 201 /> 197 202 198 - <nav className="main-nav"> 199 - <Link href="/" className="nav-logo">Drydown</Link> 200 - <div style={{ flex: 1 }} /> 201 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8, marginRight: '1rem' }}> 202 - Explore 203 - </Link> 204 - </nav> 203 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 205 204 206 205 <header style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.25rem' }}> 207 206 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', width: '100%' }}> ··· 307 306 setLocation(`/profile/${authorHandle}/review/${rkey}`) 308 307 } 309 308 }} 309 + viewerPreferences={preferences || undefined} 310 + viewerDid={session?.sub} 310 311 /> 311 312 )} 312 313
+3
src/components/LandingPage.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 2 import { useLocation } from 'wouter' 3 3 import { SEO } from './SEO' 4 + import { Header } from './Header' 4 5 import { LoginForm } from './LoginForm' 5 6 import { AppDisclaimer } from './AppDisclaimer' 6 7 import { Footer } from './Footer' ··· 168 169 description="Join the Drydown community to create time-based fragrance reviews and discover new perfumes reviewed by real people." 169 170 url="https://drydown.social" 170 171 /> 172 + 173 + <Header session={null} /> 171 174 172 175 {/* Hero Section */} 173 176 <section class="landing-hero">
+120
src/components/PreferenceSelector.tsx
··· 1 + import { useRef, useCallback } from 'preact/hooks' 2 + import { PREFERENCE_DEFINITIONS, type PreferenceKey, type PreferenceOption } from '../data/preferenceDefinitions' 3 + 4 + interface PreferenceSelectorProps { 5 + /** The preference key */ 6 + preferenceKey: PreferenceKey 7 + /** Currently selected value (1-5) or null if none selected */ 8 + value: number | null 9 + /** Callback when user selects an option */ 10 + onChange: (value: number) => void 11 + /** Disable interaction */ 12 + disabled?: boolean 13 + } 14 + 15 + /** 16 + * A statement-selection preference component for user settings. 17 + * 18 + * Users select from 5 statement options that capture their fragrance preferences. 19 + * The component stores integer values (1-5) but presents meaningful choices. 20 + * 21 + * Accessibility features: 22 + * - Radio button semantics (role="radiogroup", role="radio") 23 + * - Arrow key navigation between options 24 + * - Focus management and visible focus indicators 25 + * - Screen reader announcements for selection 26 + */ 27 + export function PreferenceSelector({ 28 + preferenceKey, 29 + value, 30 + onChange, 31 + disabled = false, 32 + }: PreferenceSelectorProps) { 33 + const preference = PREFERENCE_DEFINITIONS[preferenceKey] 34 + const containerRef = useRef<HTMLDivElement>(null) 35 + 36 + const handleKeyDown = useCallback( 37 + (e: KeyboardEvent, currentIndex: number) => { 38 + if (disabled) return 39 + 40 + let newIndex = currentIndex 41 + 42 + switch (e.key) { 43 + case 'ArrowDown': 44 + case 'ArrowRight': 45 + e.preventDefault() 46 + newIndex = Math.min(currentIndex + 1, preference.options.length - 1) 47 + break 48 + case 'ArrowUp': 49 + case 'ArrowLeft': 50 + e.preventDefault() 51 + newIndex = Math.max(currentIndex - 1, 0) 52 + break 53 + case ' ': 54 + case 'Enter': 55 + e.preventDefault() 56 + onChange(preference.options[currentIndex].value) 57 + return 58 + default: 59 + return 60 + } 61 + 62 + // Focus the new option 63 + const container = containerRef.current 64 + if (container && newIndex !== currentIndex) { 65 + const buttons = container.querySelectorAll<HTMLButtonElement>('.preference-option') 66 + buttons[newIndex]?.focus() 67 + } 68 + }, 69 + [preference.options, disabled, onChange] 70 + ) 71 + 72 + const handleSelect = useCallback( 73 + (option: PreferenceOption) => { 74 + if (!disabled) { 75 + onChange(option.value) 76 + } 77 + }, 78 + [disabled, onChange] 79 + ) 80 + 81 + return ( 82 + <div className="preference-selector"> 83 + <div className="preference-header"> 84 + <div className="preference-question" id={`preference-question-${preferenceKey}`}> 85 + {preference.question} 86 + </div> 87 + <div className="preference-description"> 88 + {preference.description} 89 + </div> 90 + </div> 91 + 92 + <div 93 + ref={containerRef} 94 + className="preference-options" 95 + role="radiogroup" 96 + aria-labelledby={`preference-question-${preferenceKey}`} 97 + > 98 + {preference.options.map((option, index) => { 99 + const isSelected = value === option.value 100 + 101 + return ( 102 + <button 103 + key={option.value} 104 + type="button" 105 + role="radio" 106 + aria-checked={isSelected} 107 + className={`preference-option ${isSelected ? 'selected' : ''} ${disabled ? 'disabled' : ''}`} 108 + onClick={() => handleSelect(option)} 109 + onKeyDown={(e) => handleKeyDown(e as unknown as KeyboardEvent, index)} 110 + disabled={disabled} 111 + tabIndex={isSelected || (value === null && index === 0) ? 0 : -1} 112 + > 113 + <span className="preference-option-text">{option.statement}</span> 114 + </button> 115 + ) 116 + })} 117 + </div> 118 + </div> 119 + ) 120 + }
+7 -8
src/components/ProfileHousesPage.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 2 import { Link } from 'wouter' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 4 import { AtpBaseClient } from '../client/index' 4 5 import { SEO } from './SEO' 6 + import { Header } from './Header' 5 7 import { Footer } from './Footer' 6 8 import { TabBar } from './TabBar' 7 9 import { resolveIdentity } from '../utils/resolveIdentity' ··· 11 13 12 14 interface ProfileHousesPageProps { 13 15 handle: string 16 + session: OAuthSession | null 17 + userProfile?: { displayName?: string; handle: string } | null 18 + onLogout?: () => void 14 19 } 15 20 16 21 interface HouseStat { ··· 23 28 24 29 type HouseFilter = 'all' | 'maintained' 25 30 26 - export function ProfileHousesPage({ handle }: ProfileHousesPageProps) { 31 + export function ProfileHousesPage({ handle, session, userProfile, onLogout }: ProfileHousesPageProps) { 27 32 const [houses, setHouses] = useState<HouseStat[]>([]) 28 33 const [isLoading, setIsLoading] = useState(true) 29 34 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string, avatar?: string } | null>(null) ··· 142 147 description={`See fragrance houses reviewed by ${profile.displayName || profile.handle} on Drydown.`} 143 148 url={window.location.href} 144 149 /> 145 - <nav className="main-nav"> 146 - <Link href="/" className="nav-logo">Drydown</Link> 147 - <div style={{ flex: 1 }} /> 148 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8, marginRight: '1rem' }}> 149 - Explore 150 - </Link> 151 - </nav> 150 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 152 151 153 152 <header style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> 154 153 {profile.avatar && (
+15 -12
src/components/ProfilePage.tsx
··· 1 1 2 2 import { useState, useEffect } from 'preact/hooks' 3 - import { useLocation, Link } from 'wouter' 3 + import { useLocation } from 'wouter' 4 + import type { OAuthSession } from '@atproto/oauth-client-browser' 4 5 import { AtpBaseClient } from '../client/index' 5 6 import { SEO } from './SEO' 7 + import { Header } from './Header' 6 8 import { Footer } from './Footer' 7 9 import { ReviewList } from './ReviewList' 8 10 import { TabBar } from './TabBar' ··· 10 12 import { batchResolveAtUris } from '../utils/atUriUtils' 11 13 import { cache, TTL } from '../services/cache' 12 14 import { getReviewDisplayScore } from '../utils/reviewUtils' 15 + import { useUserPreferences } from '../hooks/useUserPreferences' 13 16 14 17 // Helper function to render visual star ratings 15 18 function renderStars(score: number, maxStars: number = 5): string { ··· 22 25 23 26 interface ProfilePageProps { 24 27 handle: string 28 + session: OAuthSession | null 29 + userProfile?: { displayName?: string; handle: string } | null 30 + onLogout?: () => void 25 31 } 26 32 27 - export function ProfilePage({ handle }: ProfilePageProps) { 33 + export function ProfilePage({ handle, session, userProfile, onLogout }: ProfilePageProps) { 28 34 const [, setLocation] = useLocation() 29 35 const [reviews, setReviews] = useState<Array<{ uri: string; value: any }>>([]) 30 36 const [fragrances, setFragrances] = useState<Map<string, { name: string, houseName?: string }>>(new Map()) 31 37 const [isLoading, setIsLoading] = useState(true) 32 38 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string, avatar?: string } | null>(null) 33 39 const [error, setError] = useState<string | null>(null) 40 + const { preferences } = useUserPreferences(session) 34 41 35 42 useEffect(() => { 36 43 async function loadProfileAndReviews() { ··· 207 214 description={`Read ${reviews.length} fragrance reviews by ${profile.displayName || profile.handle} on Drydown.`} 208 215 url={window.location.href} 209 216 /> 210 - <nav className="main-nav"> 211 - <Link href="/" className="nav-logo">Drydown</Link> 212 - <div style={{ flex: 1 }} /> 213 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8, marginRight: '1rem' }}> 214 - Explore 215 - </Link> 216 - </nav> 217 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 217 218 218 219 <header style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}> 219 220 {profile.avatar && ( ··· 278 279 {reviews.length === 0 ? ( 279 280 <p>No reviews found for this user.</p> 280 281 ) : ( 281 - <ReviewList 282 - reviews={reviews} 283 - fragrances={fragrances} 282 + <ReviewList 283 + reviews={reviews} 284 + fragrances={fragrances} 284 285 onReviewClick={(review) => { 285 286 const rkey = review.uri.split('/').pop() 286 287 if (rkey) { 287 288 setLocation(`/profile/${handle}/review/${rkey}`) 288 289 } 289 290 }} 291 + viewerPreferences={preferences || undefined} 292 + viewerDid={session?.sub} 290 293 /> 291 294 )} 292 295
+28 -12
src/components/ReviewCard.tsx
··· 1 - import { getReviewActionState, getReviewDisplayScore } from '../utils/reviewUtils' 1 + import { getReviewActionState, getPersonalizedScore, type UserPreferencesForScoring } from '../utils/reviewUtils' 2 2 3 3 export interface AuthorInfo { 4 4 handle: string ··· 13 13 author?: AuthorInfo 14 14 status: 'active' | 'past' 15 15 onClick?: () => void 16 + viewerPreferences?: UserPreferencesForScoring 17 + viewerDid?: string 16 18 } 17 19 18 - export function ReviewCard({ review, fragranceName, houseName, author, status, onClick }: ReviewCardProps) { 20 + export function ReviewCard({ review, fragranceName, houseName, author, status, onClick, viewerPreferences, viewerDid }: ReviewCardProps) { 19 21 const { value } = review 20 22 const { hint } = getReviewActionState(value) 23 + 24 + // Determine if this is the viewer's own review 25 + const authorDid = review.uri.split('/')[2] 26 + const isOwnReview = viewerDid === authorDid 27 + 28 + // Get personalized score 29 + const { score, isPersonalized } = getPersonalizedScore(value, viewerPreferences, isOwnReview) 21 30 22 31 return ( 23 32 <div ··· 53 62 </div> 54 63 )} 55 64 </div> 56 - <div className="review-card-rating"> 57 - {(() => { 58 - const score = Math.round(getReviewDisplayScore(value)) 59 - return ( 60 - <> 61 - {'★'.repeat(score)} 62 - {'☆'.repeat(5 - score)} 63 - </> 64 - ) 65 - })()} 65 + <div style={{ textAlign: 'right' }}> 66 + <div className="review-card-rating"> 67 + {(() => { 68 + const roundedScore = Math.round(score) 69 + return ( 70 + <> 71 + {'★'.repeat(roundedScore)} 72 + {'☆'.repeat(5 - roundedScore)} 73 + </> 74 + ) 75 + })()} 76 + </div> 77 + {isPersonalized && ( 78 + <div className="personalized-score-indicator"> 79 + Based on your preferences 80 + </div> 81 + )} 66 82 </div> 67 83 </div> 68 84
+8 -2
src/components/ReviewList.tsx
··· 1 1 import { useState, useEffect } from 'preact/hooks' 2 2 import { ReviewCard } from './ReviewCard' 3 - import { categorizeReviews, calculateWeightedScore } from '../utils/reviewUtils' 3 + import { categorizeReviews, calculateWeightedScore, type UserPreferencesForScoring } from '../utils/reviewUtils' 4 4 5 5 interface ReviewListProps { 6 6 reviews: Array<{ uri: string; value: any }> 7 7 fragrances: Map<string, { name: string; houseName?: string }> 8 8 onReviewClick: (review: { uri: string; value: any }) => void 9 + viewerPreferences?: UserPreferencesForScoring 10 + viewerDid?: string 9 11 } 10 12 11 13 type SortBy = 'recent' | 'score' 12 14 type SortOrder = 'desc' | 'asc' 13 15 14 - export function ReviewList({ reviews, fragrances, onReviewClick }: ReviewListProps) { 16 + export function ReviewList({ reviews, fragrances, onReviewClick, viewerPreferences, viewerDid }: ReviewListProps) { 15 17 const { active, past } = categorizeReviews(reviews) 16 18 17 19 const [sortBy, setSortBy] = useState<SortBy>('recent') ··· 65 67 houseName={getHouseName(review.value.fragrance)} 66 68 status="active" 67 69 onClick={() => onReviewClick(review)} 70 + viewerPreferences={viewerPreferences} 71 + viewerDid={viewerDid} 68 72 /> 69 73 ))} 70 74 </section> ··· 94 98 houseName={getHouseName(review.value.fragrance)} 95 99 status="past" 96 100 onClick={() => onReviewClick(review)} 101 + viewerPreferences={viewerPreferences} 102 + viewerDid={viewerDid} 97 103 /> 98 104 ))} 99 105 </section>
+284
src/components/SettingsPage.tsx
··· 1 + import { useState, useEffect } from 'preact/hooks' 2 + import { useLocation } from 'wouter' 3 + import type { OAuthSession } from '@atproto/oauth-client-browser' 4 + import { AtpBaseClient } from '../client/index' 5 + import { PreferenceSelector } from './PreferenceSelector' 6 + import { PREFERENCE_KEYS, DEFAULT_PREFERENCES, type PreferenceKey } from '../data/preferenceDefinitions' 7 + import { Header } from './Header' 8 + import { Footer } from './Footer' 9 + import { refreshUserPreferences } from '../hooks/useUserPreferences' 10 + 11 + interface SettingsPageProps { 12 + session: OAuthSession | null 13 + userProfile?: { displayName?: string; handle: string } | null 14 + onLogout?: () => void 15 + } 16 + 17 + type ScoreLens = 'theirs' | 'mine' 18 + 19 + interface UserSettings { 20 + presenceStyle: number | null 21 + longevityPriority: number | null 22 + complexityPreference: number | null 23 + scoringApproach: number | null 24 + scoreLens: ScoreLens 25 + } 26 + 27 + export function SettingsPage({ session, userProfile, onLogout }: SettingsPageProps) { 28 + const [, setLocation] = useLocation() 29 + const [settings, setSettings] = useState<UserSettings>({ 30 + presenceStyle: null, 31 + longevityPriority: null, 32 + complexityPreference: null, 33 + scoringApproach: null, 34 + scoreLens: 'theirs', 35 + }) 36 + const [originalSettings, setOriginalSettings] = useState<UserSettings | null>(null) 37 + const [isLoading, setIsLoading] = useState(true) 38 + const [isSaving, setIsSaving] = useState(false) 39 + const [error, setError] = useState<string | null>(null) 40 + const [successMessage, setSuccessMessage] = useState<string | null>(null) 41 + 42 + // Redirect to home if not logged in 43 + useEffect(() => { 44 + if (!session) { 45 + setLocation('/') 46 + } 47 + }, [session, setLocation]) 48 + 49 + // Load existing settings 50 + useEffect(() => { 51 + if (!session) return 52 + 53 + const currentSession = session // Capture for TypeScript 54 + 55 + async function loadSettings() { 56 + try { 57 + setIsLoading(true) 58 + setError(null) 59 + 60 + const client = new AtpBaseClient(currentSession.fetchHandler.bind(currentSession)) 61 + 62 + try { 63 + const result = await client.social.drydown.settings.get({ 64 + repo: currentSession.sub, 65 + rkey: 'self', 66 + }) 67 + 68 + const loadedSettings: UserSettings = { 69 + presenceStyle: result.value.presenceStyle ?? null, 70 + longevityPriority: result.value.longevityPriority ?? null, 71 + complexityPreference: result.value.complexityPreference ?? null, 72 + scoringApproach: result.value.scoringApproach ?? null, 73 + scoreLens: (result.value.scoreLens as ScoreLens) ?? 'theirs', 74 + } 75 + 76 + setSettings(loadedSettings) 77 + setOriginalSettings(loadedSettings) 78 + } catch (e: any) { 79 + // Record not found is expected for new users 80 + if (e?.status === 400 || e?.message?.includes('not found')) { 81 + // Use defaults for new users 82 + const defaultSettings: UserSettings = { 83 + presenceStyle: DEFAULT_PREFERENCES.presenceStyle, 84 + longevityPriority: DEFAULT_PREFERENCES.longevityPriority, 85 + complexityPreference: DEFAULT_PREFERENCES.complexityPreference, 86 + scoringApproach: DEFAULT_PREFERENCES.scoringApproach, 87 + scoreLens: 'theirs', 88 + } 89 + setSettings(defaultSettings) 90 + setOriginalSettings(null) // No existing record 91 + } else { 92 + throw e 93 + } 94 + } 95 + } catch (e) { 96 + console.error('Failed to load settings', e) 97 + setError('Failed to load your preferences. Please try again.') 98 + } finally { 99 + setIsLoading(false) 100 + } 101 + } 102 + 103 + loadSettings() 104 + }, [session]) 105 + 106 + const handlePreferenceChange = (key: PreferenceKey, value: number) => { 107 + setSettings(prev => ({ ...prev, [key]: value })) 108 + setSuccessMessage(null) 109 + } 110 + 111 + const handleScoreLensChange = (lens: ScoreLens) => { 112 + setSettings(prev => ({ ...prev, scoreLens: lens })) 113 + setSuccessMessage(null) 114 + } 115 + 116 + const hasChanges = () => { 117 + if (!originalSettings) return true // New settings, always has changes 118 + return ( 119 + settings.presenceStyle !== originalSettings.presenceStyle || 120 + settings.longevityPriority !== originalSettings.longevityPriority || 121 + settings.complexityPreference !== originalSettings.complexityPreference || 122 + settings.scoringApproach !== originalSettings.scoringApproach || 123 + settings.scoreLens !== originalSettings.scoreLens 124 + ) 125 + } 126 + 127 + const handleSave = async () => { 128 + if (!session) return 129 + 130 + try { 131 + setIsSaving(true) 132 + setError(null) 133 + setSuccessMessage(null) 134 + 135 + const client = new AtpBaseClient(session.fetchHandler.bind(session)) 136 + const now = new Date().toISOString() 137 + 138 + const record = { 139 + presenceStyle: settings.presenceStyle ?? DEFAULT_PREFERENCES.presenceStyle, 140 + longevityPriority: settings.longevityPriority ?? DEFAULT_PREFERENCES.longevityPriority, 141 + complexityPreference: settings.complexityPreference ?? DEFAULT_PREFERENCES.complexityPreference, 142 + scoringApproach: settings.scoringApproach ?? DEFAULT_PREFERENCES.scoringApproach, 143 + scoreLens: settings.scoreLens, 144 + createdAt: originalSettings ? undefined : now, // Only set on first create 145 + updatedAt: now, 146 + } 147 + 148 + // Use put to create or update the record 149 + await client.social.drydown.settings.put( 150 + { repo: session.sub, rkey: 'self' }, 151 + record as any 152 + ) 153 + 154 + setOriginalSettings(settings) 155 + setSuccessMessage('Your preferences have been saved.') 156 + 157 + // Trigger refresh of preferences across the app 158 + refreshUserPreferences() 159 + } catch (e) { 160 + console.error('Failed to save settings', e) 161 + setError('Failed to save your preferences. Please try again.') 162 + } finally { 163 + setIsSaving(false) 164 + } 165 + } 166 + 167 + const handleCancel = () => { 168 + if (originalSettings) { 169 + setSettings(originalSettings) 170 + } 171 + setLocation('/') 172 + } 173 + 174 + if (!session) { 175 + return null // Will redirect 176 + } 177 + 178 + if (isLoading) { 179 + return ( 180 + <div className="page-container"> 181 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 182 + <p>Loading your preferences...</p> 183 + </div> 184 + ) 185 + } 186 + 187 + return ( 188 + <div className="page-container"> 189 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 190 + 191 + <div className="settings-page"> 192 + <header className="settings-header"> 193 + <h1>Preferences</h1> 194 + <p className="settings-intro"> 195 + These preferences personalize how fragrance scores are calculated for you. 196 + There are no wrong answers — every preference reflects a valid way to experience fragrance. 197 + </p> 198 + </header> 199 + 200 + <div className="settings-preferences"> 201 + {PREFERENCE_KEYS.map(key => ( 202 + <PreferenceSelector 203 + key={key} 204 + preferenceKey={key} 205 + value={settings[key]} 206 + onChange={(value) => handlePreferenceChange(key, value)} 207 + disabled={isSaving} 208 + /> 209 + ))} 210 + </div> 211 + 212 + <div className="settings-section"> 213 + <h2 className="settings-section-title">Score Display</h2> 214 + <p className="settings-section-description"> 215 + When viewing other people's reviews, you can see scores based on their preferences or yours. 216 + </p> 217 + 218 + <div className="score-lens-options" role="radiogroup" aria-label="Score display preference"> 219 + <button 220 + type="button" 221 + role="radio" 222 + aria-checked={settings.scoreLens === 'theirs'} 223 + className={`score-lens-option ${settings.scoreLens === 'theirs' ? 'selected' : ''}`} 224 + onClick={() => handleScoreLensChange('theirs')} 225 + disabled={isSaving} 226 + > 227 + <span className="score-lens-label">Their preferences</span> 228 + <span className="score-lens-description"> 229 + See scores as the reviewer intended 230 + </span> 231 + </button> 232 + 233 + <button 234 + type="button" 235 + role="radio" 236 + aria-checked={settings.scoreLens === 'mine'} 237 + className={`score-lens-option ${settings.scoreLens === 'mine' ? 'selected' : ''}`} 238 + onClick={() => handleScoreLensChange('mine')} 239 + disabled={isSaving} 240 + > 241 + <span className="score-lens-label">Your preferences</span> 242 + <span className="score-lens-description"> 243 + Recalculate scores based on what matters to you 244 + </span> 245 + </button> 246 + </div> 247 + </div> 248 + 249 + {error && ( 250 + <div className="error-message" role="alert"> 251 + {error} 252 + </div> 253 + )} 254 + 255 + {successMessage && ( 256 + <div className="success-message" role="status"> 257 + {successMessage} 258 + </div> 259 + )} 260 + 261 + <div className="settings-actions"> 262 + <button 263 + type="button" 264 + className="btn-secondary" 265 + onClick={handleCancel} 266 + disabled={isSaving} 267 + > 268 + cancel 269 + </button> 270 + <button 271 + type="button" 272 + className="btn-primary" 273 + onClick={handleSave} 274 + disabled={isSaving || !hasChanges()} 275 + > 276 + {isSaving ? 'saving...' : 'save preferences'} 277 + </button> 278 + </div> 279 + </div> 280 + 281 + <Footer session={session} /> 282 + </div> 283 + ) 284 + }
+33 -17
src/components/SingleReviewPage.tsx
··· 1 1 2 2 import { useState, useEffect } from 'preact/hooks' 3 3 import { Link, useLocation } from 'wouter' 4 + import type { OAuthSession } from '@atproto/oauth-client-browser' 4 5 import { AtpBaseClient } from '../client/index' 5 6 import { SEO } from '../components/SEO' 7 + import { Header } from '../components/Header' 6 8 import { Footer } from '../components/Footer' 7 9 import { resolveIdentity } from '../utils/resolveIdentity' 8 10 import { resolveAtUri } from '../utils/atUriUtils' 9 - import { getReviewActionState, getReviewDisplayScore } from '../utils/reviewUtils' 10 - import type { OAuthSession } from '@atproto/oauth-client-browser' 11 + import { getReviewActionState, getPersonalizedScore } from '../utils/reviewUtils' 12 + import { useUserPreferences } from '../hooks/useUserPreferences' 11 13 12 14 /** 13 15 * Calculate average temperature from review's weather data ··· 29 31 interface SingleReviewPageProps { 30 32 handle: string 31 33 rkey: string 32 - session?: OAuthSession | null 34 + session: OAuthSession | null 35 + userProfile?: { displayName?: string; handle: string } | null 36 + onLogout?: () => void 33 37 } 34 38 35 - export function SingleReviewPage({ handle, rkey, session }: SingleReviewPageProps) { 39 + export function SingleReviewPage({ handle, rkey, session, userProfile, onLogout }: SingleReviewPageProps) { 36 40 const [review, setReview] = useState<any | null>(null) 37 41 const [fragrance, setFragrance] = useState<any | null>(null) 38 42 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string } | null>(null) 39 43 const [isLoading, setIsLoading] = useState(true) 40 44 const [error, setError] = useState<string | null>(null) 41 - 45 + 42 46 const [, setLocation] = useLocation() 47 + const { preferences } = useUserPreferences(session) 43 48 44 49 useEffect(() => { 45 50 async function loadReviewData() { ··· 135 140 const handleShare = () => { 136 141 if (!review || !fragrance) return 137 142 138 - const displayScore = Math.round(getReviewDisplayScore(review)) 143 + // Determine if viewing own review 144 + const isOwnReview = !!(profile && session?.sub === profile.did) 145 + const { score } = getPersonalizedScore(review, preferences || undefined, isOwnReview) 146 + const displayScore = Math.round(score) 139 147 const stars = '★'.repeat(displayScore) + '☆'.repeat(5 - displayScore) 140 148 const link = window.location.href 141 149 const houseName = fragrance?.houseName || 'Unknown House' ··· 198 206 const handleShareOther = () => { 199 207 if (!review || !fragrance || !profile) return 200 208 201 - const displayScore = Math.round(getReviewDisplayScore(review)) 209 + // Determine if viewing own review 210 + const isOwnReview = !!(session?.sub === profile.did) 211 + const { score } = getPersonalizedScore(review, preferences || undefined, isOwnReview) 212 + const displayScore = Math.round(score) 202 213 const stars = '★'.repeat(displayScore) + '☆'.repeat(5 - displayScore) 203 214 const via = ` — via @${profile.handle}` 204 215 const starsLine = stars + via ··· 284 295 if (!review) return <div class="page-container">Review not found</div> 285 296 286 297 298 + // Calculate personalized score for display 299 + const isOwnReview = !!(profile && session?.sub === profile.did) 300 + const { score: personalizedScore, isPersonalized } = getPersonalizedScore(review, preferences || undefined, isOwnReview) 301 + 287 302 const fragName = fragrance?.name || 'Unknown Fragrance' 288 - const displayScore = Math.round(getReviewDisplayScore(review)) 303 + const displayScore = Math.round(personalizedScore) 289 304 const stars = '★'.repeat(displayScore) + '☆'.repeat(5 - displayScore) 290 305 const metaDescription = `${stars} ${review.text ? review.text.slice(0, 150) + (review.text.length > 150 ? '...' : '') : 'Read this review on Drydown.'}` 291 306 ··· 298 313 description={metaDescription} 299 314 url={window.location.href} 300 315 /> 301 - <nav className="main-nav"> 302 - <Link href="/" className="nav-logo">Drydown</Link> 303 - <div style={{ flex: 1 }} /> 304 - <Link href="/explore" style={{ textDecoration: 'none', color: 'inherit', opacity: 0.8, marginRight: '1rem' }}> 305 - Explore 306 - </Link> 307 - </nav> 316 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 308 317 309 318 <Link href={`/profile/${handle}/reviews`} className="back-link"> 310 319 &larr; Back to {profile?.displayName || handle}'s reviews ··· 372 381 373 382 <div class="review-content"> 374 383 <div className="review-meta"> 375 - <div class="rating-large"> 376 - {Math.round(getReviewDisplayScore(review))}/5 384 + <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}> 385 + <div class="rating-large"> 386 + {displayScore}/5 387 + </div> 388 + {isPersonalized && ( 389 + <div className="personalized-score-indicator" style={{ marginTop: '0' }}> 390 + Based on your preferences 391 + </div> 392 + )} 377 393 </div> 378 394 <div className="review-date"> 379 395 Rated on {new Date(review.createdAt).toLocaleDateString()}
+1 -1
src/config.ts
··· 3 3 4 4 export const oauthConfig = isDev 5 5 ? { 6 - clientId: `http://localhost?redirect_uri=${encodeURIComponent(`http://127.0.0.1:${port}`)}&scope=${encodeURIComponent('atproto repo:social.drydown.house repo:social.drydown.fragrance repo:social.drydown.review rpc:app.bsky.actor.getProfile?aud=* rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.getTimeline?aud=* rpc:app.bsky.feed.post?aud=*')}`, 6 + clientId: `http://localhost?redirect_uri=${encodeURIComponent(`http://127.0.0.1:${port}`)}&scope=${encodeURIComponent('atproto repo:social.drydown.house repo:social.drydown.fragrance repo:social.drydown.review repo:social.drydown.settings rpc:app.bsky.actor.getProfile?aud=* rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.getTimeline?aud=* rpc:app.bsky.feed.post?aud=*')}`, 7 7 redirectUri: `http://127.0.0.1:${port}`, 8 8 } 9 9 : {
+96
src/data/preferenceDefinitions.ts
··· 1 + /** 2 + * Preference definitions for user fragrance settings. 3 + * 4 + * Each preference has 5 statement-based options that map to integer values 1-5. 5 + * These preferences translate into scoring weights that personalize how reviews 6 + * are evaluated. The underlying data model stores integers; these statements 7 + * provide meaningful context for users. 8 + */ 9 + 10 + export interface PreferenceOption { 11 + value: 1 | 2 | 3 | 4 | 5 12 + statement: string 13 + } 14 + 15 + export interface PreferenceDefinition { 16 + key: PreferenceKey 17 + question: string 18 + description: string 19 + options: readonly [PreferenceOption, PreferenceOption, PreferenceOption, PreferenceOption, PreferenceOption] 20 + } 21 + 22 + export type PreferenceKey = 23 + | 'presenceStyle' 24 + | 'longevityPriority' 25 + | 'complexityPreference' 26 + | 'scoringApproach' 27 + 28 + export const PREFERENCE_DEFINITIONS: Record<PreferenceKey, PreferenceDefinition> = { 29 + presenceStyle: { 30 + key: 'presenceStyle', 31 + question: 'How do you like to be noticed?', 32 + description: 'This affects how much weight projection and sillage have in your scores.', 33 + options: [ 34 + { value: 1, statement: 'Skin scent only — just for me' }, 35 + { value: 2, statement: 'Close company — noticeable only up close' }, 36 + { value: 3, statement: 'Balanced — depends on the occasion' }, 37 + { value: 4, statement: 'Room presence — I enjoy being noticed' }, 38 + { value: 5, statement: 'Announce myself — I like to make an entrance' }, 39 + ], 40 + }, 41 + 42 + longevityPriority: { 43 + key: 'longevityPriority', 44 + question: 'How important is all-day longevity?', 45 + description: 'This affects how much weight longevity and late-stage ratings have in your scores.', 46 + options: [ 47 + { value: 1, statement: 'Not important — I enjoy reapplying throughout the day' }, 48 + { value: 2, statement: 'Nice to have — but not a dealbreaker' }, 49 + { value: 3, statement: 'Moderately important — I appreciate good lasting power' }, 50 + { value: 4, statement: 'Very important — I want it to last my workday' }, 51 + { value: 5, statement: 'Essential — all-day performance is a must' }, 52 + ], 53 + }, 54 + 55 + complexityPreference: { 56 + key: 'complexityPreference', 57 + question: 'What kind of compositions appeal to you?', 58 + description: 'This affects how much weight complexity has in your scores.', 59 + options: [ 60 + { value: 1, statement: 'Simple and focused — I prefer clean, straightforward scents' }, 61 + { value: 2, statement: 'Mostly simple — with maybe a twist or two' }, 62 + { value: 3, statement: 'Balanced — I enjoy both simple and complex fragrances' }, 63 + { value: 4, statement: 'Layered — I like discovering new notes over time' }, 64 + { value: 5, statement: 'Intricate — the more depth and evolution, the better' }, 65 + ], 66 + }, 67 + 68 + scoringApproach: { 69 + key: 'scoringApproach', 70 + question: 'How do you evaluate fragrances overall?', 71 + description: 'This affects whether your gut feeling or technical ratings carry more weight.', 72 + options: [ 73 + { value: 1, statement: 'Pure instinct — my gut reaction is what matters most' }, 74 + { value: 2, statement: 'Mostly instinct — but I consider the details too' }, 75 + { value: 3, statement: 'Balanced — both feeling and analysis matter equally' }, 76 + { value: 4, statement: 'Mostly analytical — I weigh each aspect carefully' }, 77 + { value: 5, statement: 'Fully analytical — the numbers tell the real story' }, 78 + ], 79 + }, 80 + } as const 81 + 82 + /** All preference keys in order */ 83 + export const PREFERENCE_KEYS: readonly PreferenceKey[] = [ 84 + 'presenceStyle', 85 + 'longevityPriority', 86 + 'complexityPreference', 87 + 'scoringApproach', 88 + ] as const 89 + 90 + /** Default preference values (balanced/neutral) */ 91 + export const DEFAULT_PREFERENCES: Record<PreferenceKey, number> = { 92 + presenceStyle: 3, 93 + longevityPriority: 3, 94 + complexityPreference: 3, 95 + scoringApproach: 3, 96 + }
+92
src/hooks/useUserPreferences.ts
··· 1 + import { useState, useEffect } from 'preact/hooks' 2 + import type { OAuthSession } from '@atproto/oauth-client-browser' 3 + import { AtpBaseClient } from '../client/index' 4 + import type { UserPreferencesForScoring } from '../utils/reviewUtils' 5 + 6 + // Global refresh key to trigger reload across all instances 7 + let globalRefreshKey = 0 8 + 9 + /** 10 + * Trigger all useUserPreferences hooks to reload. 11 + * Call this after updating user preferences. 12 + */ 13 + export function refreshUserPreferences() { 14 + globalRefreshKey++ 15 + // Dispatch custom event to notify all hooks 16 + window.dispatchEvent(new CustomEvent('user-preferences-updated')) 17 + } 18 + 19 + /** 20 + * Hook to load and cache the current user's preferences. 21 + * Returns null if not signed in or no preferences set yet. 22 + */ 23 + export function useUserPreferences(session: OAuthSession | null): { 24 + preferences: UserPreferencesForScoring | null 25 + isLoading: boolean 26 + refresh: () => void 27 + } { 28 + const [preferences, setPreferences] = useState<UserPreferencesForScoring | null>(null) 29 + const [isLoading, setIsLoading] = useState(true) 30 + const [refreshKey, setRefreshKey] = useState(0) 31 + 32 + // Manual refresh function 33 + const refresh = () => { 34 + setRefreshKey(prev => prev + 1) 35 + } 36 + 37 + useEffect(() => { 38 + if (!session) { 39 + setPreferences(null) 40 + setIsLoading(false) 41 + return 42 + } 43 + 44 + async function loadPreferences() { 45 + if (!session) return 46 + 47 + try { 48 + setIsLoading(true) 49 + const client = new AtpBaseClient(session.fetchHandler.bind(session)) 50 + 51 + const result = await client.social.drydown.settings.get({ 52 + repo: session.sub, 53 + rkey: 'self', 54 + }) 55 + 56 + setPreferences({ 57 + presenceStyle: result.value.presenceStyle, 58 + longevityPriority: result.value.longevityPriority, 59 + complexityPreference: result.value.complexityPreference, 60 + scoringApproach: result.value.scoringApproach, 61 + scoreLens: result.value.scoreLens as 'theirs' | 'mine' | undefined, 62 + }) 63 + } catch (e: any) { 64 + // No preferences set yet is expected for new users 65 + if (e?.status === 400 || e?.message?.includes('not found')) { 66 + setPreferences(null) 67 + } else { 68 + console.error('Failed to load user preferences', e) 69 + setPreferences(null) 70 + } 71 + } finally { 72 + setIsLoading(false) 73 + } 74 + } 75 + 76 + loadPreferences() 77 + }, [session, refreshKey]) 78 + 79 + // Listen for global refresh events 80 + useEffect(() => { 81 + const handleRefresh = () => { 82 + setRefreshKey(prev => prev + 1) 83 + } 84 + 85 + window.addEventListener('user-preferences-updated', handleRefresh) 86 + return () => { 87 + window.removeEventListener('user-preferences-updated', handleRefresh) 88 + } 89 + }, []) 90 + 91 + return { preferences, isLoading, refresh } 92 + }
+57
src/lexicons/social.drydown.settings.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.drydown.settings", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "User preferences for fragrance review scoring", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["createdAt"], 12 + "properties": { 13 + "presenceStyle": { 14 + "type": "integer", 15 + "minimum": 1, 16 + "maximum": 5, 17 + "description": "How the user prefers to be noticed (1=skin scent, 5=bold presence)" 18 + }, 19 + "longevityPriority": { 20 + "type": "integer", 21 + "minimum": 1, 22 + "maximum": 5, 23 + "description": "How important all-day longevity is (1=not important, 5=essential)" 24 + }, 25 + "complexityPreference": { 26 + "type": "integer", 27 + "minimum": 1, 28 + "maximum": 5, 29 + "description": "Preference for fragrance complexity (1=simple, 5=intricate)" 30 + }, 31 + "scoringApproach": { 32 + "type": "integer", 33 + "minimum": 1, 34 + "maximum": 5, 35 + "description": "How user evaluates fragrances (1=instinct, 5=analytical)" 36 + }, 37 + "scoreLens": { 38 + "type": "string", 39 + "maxLength": 10, 40 + "knownValues": ["theirs", "mine"], 41 + "description": "When viewing others' reviews: show their score or recalculate with your preferences" 42 + }, 43 + "createdAt": { 44 + "type": "string", 45 + "format": "datetime", 46 + "description": "Timestamp when settings were first created" 47 + }, 48 + "updatedAt": { 49 + "type": "string", 50 + "format": "datetime", 51 + "description": "Timestamp when settings were last updated" 52 + } 53 + } 54 + } 55 + } 56 + } 57 + }
+80
src/utils/reviewUtils.ts
··· 170 170 return calculateWeightedScore(review) 171 171 } 172 172 173 + export type ScoreLens = 'theirs' | 'mine' 174 + 175 + export interface UserPreferencesForScoring { 176 + presenceStyle?: number 177 + longevityPriority?: number 178 + complexityPreference?: number 179 + scoringApproach?: number 180 + scoreLens?: ScoreLens 181 + } 182 + 183 + /** 184 + * Get the display score for a review with optional personalized scoring. 185 + * 186 + * @param review - The review record 187 + * @param userPreferences - Optional user preferences for personalized scoring 188 + * @param isOwnReview - Whether this review belongs to the current user 189 + * @returns Object with score and whether it's personalized 190 + */ 191 + export function getPersonalizedScore( 192 + review: any, 193 + userPreferences?: UserPreferencesForScoring, 194 + isOwnReview: boolean = false 195 + ): { score: number; isPersonalized: boolean } { 196 + // If no preferences or viewing own review or lens is "theirs", use original score 197 + if (!userPreferences || isOwnReview || userPreferences.scoreLens !== 'mine') { 198 + return { 199 + score: getReviewDisplayScore(review), 200 + isPersonalized: false, 201 + } 202 + } 203 + 204 + // Recalculate with user's preferences 205 + const weights = mapPreferencesToWeights(userPreferences) 206 + return { 207 + score: calculateWeightedScore(review, weights), 208 + isPersonalized: true, 209 + } 210 + } 211 + 212 + /** 213 + * Maps user preferences to scoring weights. 214 + * This is a simplified version for use in reviewUtils. 215 + */ 216 + function mapPreferencesToWeights(preferences: UserPreferencesForScoring) { 217 + const WEIGHT_SCALE: Record<number, number> = { 218 + 1: 0.5, 219 + 2: 0.75, 220 + 3: 1.0, 221 + 4: 1.25, 222 + 5: 1.5, 223 + } 224 + 225 + const SCORING_APPROACH_OVERALL_SCALE: Record<number, number> = { 226 + 1: 1.5, 227 + 2: 1.25, 228 + 3: 1.0, 229 + 4: 0.75, 230 + 5: 0.5, 231 + } 232 + 233 + const presenceMultiplier = WEIGHT_SCALE[preferences.presenceStyle ?? 3] ?? 1.0 234 + const longevityMultiplier = WEIGHT_SCALE[preferences.longevityPriority ?? 3] ?? 1.0 235 + const complexityMultiplier = WEIGHT_SCALE[preferences.complexityPreference ?? 3] ?? 1.0 236 + const scoringApproach = preferences.scoringApproach ?? 3 237 + const overallMultiplier = SCORING_APPROACH_OVERALL_SCALE[scoringApproach] ?? 1.0 238 + const technicalBoost = scoringApproach >= 4 ? 1.1 : 1.0 239 + 240 + return { 241 + openingRating: 1.0 * technicalBoost, 242 + openingProjection: presenceMultiplier * technicalBoost, 243 + drydownRating: 1.0 * technicalBoost, 244 + midProjection: presenceMultiplier * technicalBoost, 245 + sillage: presenceMultiplier * technicalBoost, 246 + endRating: longevityMultiplier * technicalBoost, 247 + complexity: complexityMultiplier * technicalBoost, 248 + longevity: longevityMultiplier * technicalBoost, 249 + overallRating: overallMultiplier, 250 + } 251 + } 252 + 173 253 /** 174 254 * Format notification time window for a specific stage 175 255 * Returns formatted text like "in 1.5-4 hours" or specific times
+101
src/utils/weightMapping.ts
··· 1 + /** 2 + * Maps user preferences to scoring weights. 3 + * 4 + * Each preference (1-5) affects specific rating criteria weights. 5 + * The default weight is 1.0. Preferences adjust weights up or down 6 + * based on what matters more or less to the user. 7 + */ 8 + 9 + import type { PreferenceKey } from '../data/preferenceDefinitions' 10 + 11 + export interface ScoringWeights { 12 + openingRating: number 13 + openingProjection: number 14 + drydownRating: number 15 + midProjection: number 16 + sillage: number 17 + endRating: number 18 + complexity: number 19 + longevity: number 20 + overallRating: number 21 + } 22 + 23 + export type UserPreferences = Record<PreferenceKey, number> 24 + 25 + /** 26 + * Weight multipliers for each preference level. 27 + * Level 3 (balanced) = 1.0, levels below decrease, levels above increase. 28 + */ 29 + const WEIGHT_SCALE = { 30 + 1: 0.5, 31 + 2: 0.75, 32 + 3: 1.0, 33 + 4: 1.25, 34 + 5: 1.5, 35 + } as const 36 + 37 + /** 38 + * For scoring approach, we invert the scale for overallRating. 39 + * Level 1 (pure instinct) means overall rating matters MORE. 40 + * Level 5 (fully analytical) means overall rating matters LESS (technical ratings matter more). 41 + */ 42 + const SCORING_APPROACH_OVERALL_SCALE = { 43 + 1: 1.5, // Instinct: overall rating weighs heavily 44 + 2: 1.25, 45 + 3: 1.0, 46 + 4: 0.75, 47 + 5: 0.5, // Analytical: overall rating weighs less 48 + } as const 49 + 50 + /** 51 + * Maps user preferences to scoring weights. 52 + * 53 + * @param preferences - User's preference values (1-5 for each) 54 + * @returns Weights to use in calculateWeightedScore 55 + */ 56 + export function mapPreferencesToWeights(preferences: UserPreferences): ScoringWeights { 57 + const presenceMultiplier = WEIGHT_SCALE[preferences.presenceStyle as keyof typeof WEIGHT_SCALE] ?? 1.0 58 + const longevityMultiplier = WEIGHT_SCALE[preferences.longevityPriority as keyof typeof WEIGHT_SCALE] ?? 1.0 59 + const complexityMultiplier = WEIGHT_SCALE[preferences.complexityPreference as keyof typeof WEIGHT_SCALE] ?? 1.0 60 + const scoringApproach = preferences.scoringApproach as keyof typeof SCORING_APPROACH_OVERALL_SCALE 61 + const overallMultiplier = SCORING_APPROACH_OVERALL_SCALE[scoringApproach] ?? 1.0 62 + 63 + // For analytical approach, boost all technical ratings slightly 64 + // This creates the balance: instinct = overall matters more, analytical = details matter more 65 + const technicalBoost = scoringApproach >= 4 ? 1.1 : 1.0 66 + 67 + return { 68 + // Opening ratings - presence affects projection 69 + openingRating: 1.0 * technicalBoost, 70 + openingProjection: presenceMultiplier * technicalBoost, 71 + 72 + // Mid-wear ratings - presence affects projection and sillage 73 + drydownRating: 1.0 * technicalBoost, 74 + midProjection: presenceMultiplier * technicalBoost, 75 + sillage: presenceMultiplier * technicalBoost, 76 + 77 + // Final ratings - longevity and complexity preferences 78 + endRating: longevityMultiplier * technicalBoost, 79 + complexity: complexityMultiplier * technicalBoost, 80 + longevity: longevityMultiplier * technicalBoost, 81 + 82 + // Overall gut rating - affected by scoring approach 83 + overallRating: overallMultiplier, 84 + } 85 + } 86 + 87 + /** 88 + * Default weights when no preferences are set. 89 + * All weights are 1.0 (equal importance). 90 + */ 91 + export const DEFAULT_WEIGHTS: ScoringWeights = { 92 + openingRating: 1.0, 93 + openingProjection: 1.0, 94 + drydownRating: 1.0, 95 + midProjection: 1.0, 96 + sillage: 1.0, 97 + endRating: 1.0, 98 + complexity: 1.0, 99 + longevity: 1.0, 100 + overallRating: 1.0, 101 + }