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

cleaning up house section of profiles

+235 -83
+57
src/app.css
··· 217 217 margin: 0.5rem 0 0 0; 218 218 } 219 219 220 + /* Tab Bar Styles */ 221 + .tab-bar { 222 + display: flex; 223 + gap: 0.5rem; 224 + border-bottom: 1px solid var(--border-color); 225 + margin-bottom: 2rem; 226 + } 227 + 228 + .tab-item { 229 + padding: 0.75rem 1rem; 230 + font-size: 1rem; 231 + text-decoration: none; 232 + color: var(--text-color); 233 + opacity: 0.5; 234 + transition: opacity 0.2s ease; 235 + } 236 + 237 + .tab-item:hover { 238 + opacity: 0.7; 239 + } 240 + 241 + .tab-item--active { 242 + opacity: 1; 243 + font-weight: 600; 244 + border-bottom: 2px solid var(--text-color); 245 + } 246 + 220 247 /* Shared Header/Nav Styles */ 221 248 .main-nav { 222 249 display: flex; ··· 492 519 font-size: 0.85rem; 493 520 opacity: 0.7; 494 521 color: var(--text-secondary); 522 + } 523 + 524 + /* House Filter Toggle */ 525 + .house-filter { 526 + display: flex; 527 + gap: 0.5rem; 528 + border-bottom: 1px solid var(--border-color); 529 + margin-bottom: 2rem; 530 + } 531 + 532 + .house-filter-button { 533 + background: none; 534 + border: none; 535 + padding: 0.75rem 1rem; 536 + font-size: 1rem; 537 + color: var(--text-color); 538 + opacity: 0.5; 539 + cursor: pointer; 540 + transition: opacity 0.2s ease; 541 + border-bottom: 2px solid transparent; 542 + } 543 + 544 + .house-filter-button:hover { 545 + opacity: 0.7; 546 + } 547 + 548 + .house-filter-button.active { 549 + opacity: 1; 550 + font-weight: 600; 551 + border-bottom-color: var(--text-color); 495 552 } 496 553 497 554 /* Reviews Section */
+127 -18
src/components/ProfileHousesPage.tsx
··· 2 2 import { Link } from 'wouter' 3 3 import { AtpBaseClient } from '../client/index' 4 4 import { SEO } from './SEO' 5 + import { TabBar } from './TabBar' 5 6 import { resolveIdentity } from '../utils/resolveIdentity' 6 7 import { cache, TTL } from '../services/cache' 8 + import { getReviewDisplayScore } from '../utils/reviewUtils' 7 9 8 10 interface ProfileHousesPageProps { 9 11 handle: string ··· 13 15 uri: string 14 16 name: string 15 17 reviewCount: number 18 + totalScore: number 19 + avgScore: number 16 20 } 17 21 22 + type HouseFilter = 'all' | 'maintained' 23 + 18 24 export function ProfileHousesPage({ handle }: ProfileHousesPageProps) { 19 25 const [houses, setHouses] = useState<HouseStat[]>([]) 20 26 const [isLoading, setIsLoading] = useState(true) 21 27 const [profile, setProfile] = useState<{ displayName?: string, handle: string, did: string, avatar?: string } | null>(null) 22 28 const [error, setError] = useState<string | null>(null) 29 + const [houseFilter, setHouseFilter] = useState<HouseFilter>('all') 23 30 24 31 useEffect(() => { 25 32 async function loadProfileAndHouses() { ··· 89 96 }) 90 97 ) 91 98 92 - // Aggregate review counts by house URI 99 + // Aggregate review counts and scores by house URI 93 100 const houseCounts = new Map<string, HouseStat>() 94 - 101 + 95 102 for (const review of reviewRecords.records) { 96 103 const fragUri = (review.value as any).fragrance 97 104 const fragInfo = fragranceMap.get(fragUri) 98 - 105 + const score = getReviewDisplayScore(review.value) 106 + 99 107 if (fragInfo && fragInfo.houseUri) { 100 108 const current = houseCounts.get(fragInfo.houseUri) || { 101 109 uri: fragInfo.houseUri, 102 110 name: fragInfo.houseName, 103 - reviewCount: 0 111 + reviewCount: 0, 112 + totalScore: 0, 113 + avgScore: 0 104 114 } 105 115 current.reviewCount++ 116 + current.totalScore += score 117 + current.avgScore = current.totalScore / current.reviewCount 106 118 houseCounts.set(fragInfo.houseUri, current) 107 119 } 108 120 } ··· 165 177 </div> 166 178 </header> 167 179 168 - <div style={{ display: 'flex', gap: '1rem', borderBottom: '1px solid rgba(255,255,255,0.1)', marginBottom: '2rem', paddingBottom: '0.5rem' }}> 169 - <Link href={`/profile/${handle}/reviews`} style={{ textDecoration: 'none', color: 'inherit', opacity: 0.6, padding: '0.5rem 1rem' }}> 170 - Reviews 171 - </Link> 172 - <div style={{ borderBottom: '2px solid white', padding: '0.5rem 1rem', fontWeight: 'bold' }}> 173 - Houses 180 + <TabBar 181 + tabs={[ 182 + { label: 'Reviews', href: `/profile/${handle}/reviews` }, 183 + { label: 'Houses' }, 184 + ]} 185 + /> 186 + 187 + {houses.length > 0 && (() => { 188 + // Calculate stats 189 + const totalHouses = houses.length 190 + const maintainedHouses = houses.filter(house => { 191 + const [,, houseDid] = house.uri.split('/') 192 + return houseDid === profile.did 193 + }).length 194 + 195 + let favoriteHouse = 'N/A' 196 + let favoriteScore = 0 197 + let leastFavoriteHouse = 'N/A' 198 + let leastFavoriteScore = Infinity 199 + 200 + for (const house of houses) { 201 + if (house.avgScore > favoriteScore) { 202 + favoriteScore = house.avgScore 203 + favoriteHouse = house.name 204 + } 205 + if (house.avgScore < leastFavoriteScore) { 206 + leastFavoriteScore = house.avgScore 207 + leastFavoriteHouse = house.name 208 + } 209 + } 210 + 211 + // Helper function to render visual star ratings 212 + const renderStars = (score: number, maxStars: number = 5): string => { 213 + const roundedScore = Math.round(score) 214 + const fullStars = roundedScore 215 + const emptyStars = maxStars - fullStars 216 + return '★'.repeat(fullStars) + '☆'.repeat(emptyStars) 217 + } 218 + 219 + return ( 220 + <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem', marginBottom: '2rem' }}> 221 + <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> 222 + <div class="score-item"> 223 + <div className="score-label">Total Houses Reviewed</div> 224 + <div className="score-value">{totalHouses}</div> 225 + </div> 226 + <div class="score-item"> 227 + <div className="score-label">Houses Maintained</div> 228 + <div className="score-value">{maintainedHouses}</div> 229 + </div> 230 + </div> 231 + <div class="score-item"> 232 + <div className="score-label">Favorite House</div> 233 + <div className="score-value"> 234 + <div style={{ fontSize: '1.1rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 235 + {favoriteHouse} 236 + </div> 237 + {favoriteScore > 0 && ( 238 + <div style={{ fontSize: '0.9rem', opacity: 0.6, marginTop: '0.25rem' }}> 239 + {renderStars(favoriteScore)} ({favoriteScore.toFixed(1)}) 240 + </div> 241 + )} 242 + </div> 243 + </div> 244 + <div class="score-item"> 245 + <div className="score-label">Least Favorite House</div> 246 + <div className="score-value"> 247 + <div style={{ fontSize: '1.1rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 248 + {leastFavoriteHouse} 249 + </div> 250 + {leastFavoriteScore < Infinity && ( 251 + <div style={{ fontSize: '0.9rem', opacity: 0.6, marginTop: '0.25rem' }}> 252 + {renderStars(leastFavoriteScore)} ({leastFavoriteScore.toFixed(1)}) 253 + </div> 254 + )} 255 + </div> 256 + </div> 174 257 </div> 175 - </div> 258 + ) 259 + })()} 176 260 177 261 <h2>Houses Reviewed</h2> 178 - {houses.length === 0 ? ( 179 - <p>No houses found for this user.</p> 180 - ) : ( 181 - <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}> 182 - {houses.map(house => { 262 + 263 + <div className="house-filter"> 264 + <button 265 + className={`house-filter-button ${houseFilter === 'all' ? 'active' : ''}`} 266 + onClick={() => setHouseFilter('all')} 267 + > 268 + All 269 + </button> 270 + <button 271 + className={`house-filter-button ${houseFilter === 'maintained' ? 'active' : ''}`} 272 + onClick={() => setHouseFilter('maintained')} 273 + > 274 + Maintained 275 + </button> 276 + </div> 277 + {(() => { 278 + const filteredHouses = houses.filter(house => { 279 + if (houseFilter === 'maintained') { 280 + const [,, houseDid] = house.uri.split('/') 281 + return houseDid === profile.did 282 + } 283 + return true // 'all' shows everything 284 + }) 285 + 286 + return filteredHouses.length === 0 ? ( 287 + <p>No {houseFilter === 'maintained' ? 'maintained ' : ''}houses found.</p> 288 + ) : ( 289 + <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}> 290 + {filteredHouses.map(house => { 183 291 const rkey = house.uri.split('/').pop() 184 292 return ( 185 293 <Link key={house.uri} href={`/profile/${handle}/house/${rkey}`} style={{ textDecoration: 'none', color: 'inherit' }}> ··· 192 300 </Link> 193 301 ) 194 302 })} 195 - </div> 196 - )} 303 + </div> 304 + ) 305 + })()} 197 306 </div> 198 307 ) 199 308 }
+7 -65
src/components/ProfilePage.tsx
··· 4 4 import { AtpBaseClient } from '../client/index' 5 5 import { SEO } from './SEO' 6 6 import { ReviewList } from './ReviewList' 7 + import { TabBar } from './TabBar' 7 8 import { resolveIdentity } from '../utils/resolveIdentity' 8 9 import { cache, TTL } from '../services/cache' 9 10 import { getReviewDisplayScore } from '../utils/reviewUtils' ··· 137 138 let favoriteScore = 0 138 139 let leastFavoriteFragrance = 'N/A' 139 140 let leastFavoriteScore = 0 140 - let favoriteHouse = 'N/A' 141 141 142 142 if (numReviews > 0) { 143 143 interface FragranceStats { 144 144 name: string 145 - houseName?: string 146 145 totalScore: number 147 146 reviewCount: number 148 147 mostRecentDate: string 149 148 maxScore: number // Highest single review score 150 - } 151 - 152 - interface HouseStats { 153 - name: string 154 - totalFragranceScore: number 155 - fragranceCount: number 156 - mostRecentDate: string 157 149 } 158 150 159 151 let totalRating = 0 ··· 172 164 if (current) { 173 165 fragranceStats.set(fragUri, { 174 166 name: fragInfo.name, 175 - houseName: fragInfo.houseName, 176 167 totalScore: current.totalScore + score, 177 168 reviewCount: current.reviewCount + 1, 178 169 mostRecentDate: review.value.createdAt > current.mostRecentDate ··· 183 174 } else { 184 175 fragranceStats.set(fragUri, { 185 176 name: fragInfo.name, 186 - houseName: fragInfo.houseName, 187 177 totalScore: score, 188 178 reviewCount: 1, 189 179 mostRecentDate: review.value.createdAt, ··· 195 185 196 186 avgRating = Math.round((totalRating / numReviews) * 10) / 10 197 187 198 - // Pass 2: build house stats by averaging per-fragrance scores. 199 - // Each fragrance contributes one value (its own average) so that a 200 - // heavily-reviewed fragrance doesn't distort the house score. 201 - const houseStats = new Map<string, HouseStats>() 202 - for (const [_, stats] of fragranceStats.entries()) { 203 - if (!stats.houseName || stats.houseName === 'Unknown House') continue 204 - const fragAvg = stats.totalScore / stats.reviewCount 205 - const currentHouse = houseStats.get(stats.houseName) 206 - if (currentHouse) { 207 - houseStats.set(stats.houseName, { 208 - name: stats.houseName, 209 - totalFragranceScore: currentHouse.totalFragranceScore + fragAvg, 210 - fragranceCount: currentHouse.fragranceCount + 1, 211 - mostRecentDate: stats.mostRecentDate > currentHouse.mostRecentDate 212 - ? stats.mostRecentDate 213 - : currentHouse.mostRecentDate 214 - }) 215 - } else { 216 - houseStats.set(stats.houseName, { 217 - name: stats.houseName, 218 - totalFragranceScore: fragAvg, 219 - fragranceCount: 1, 220 - mostRecentDate: stats.mostRecentDate 221 - }) 222 - } 223 - } 224 - 225 188 // Find favorite fragrance by highest single review score (with recency tiebreaker) 226 189 let bestMaxScore = 0 227 190 let bestDate = '' ··· 247 210 leastFavoriteScore = stats.maxScore 248 211 } 249 212 } 250 - 251 - // Find favorite house by highest average fragrance score (with recency tiebreaker) 252 - let bestHouseScore = 0 253 - let bestHouseDate = '' 254 - for (const [_, stats] of houseStats.entries()) { 255 - const avgScore = stats.totalFragranceScore / stats.fragranceCount 256 - if (avgScore > bestHouseScore || 257 - (avgScore === bestHouseScore && stats.mostRecentDate > bestHouseDate)) { 258 - bestHouseScore = avgScore 259 - bestHouseDate = stats.mostRecentDate 260 - favoriteHouse = stats.name 261 - } 262 - } 263 213 } 264 214 265 215 return ( ··· 292 242 </div> 293 243 </header> 294 244 295 - <div style={{ display: 'flex', gap: '1rem', borderBottom: '1px solid rgba(255,255,255,0.1)', marginBottom: '2rem', paddingBottom: '0.5rem' }}> 296 - <div style={{ borderBottom: '2px solid white', padding: '0.5rem 1rem', fontWeight: 'bold' }}> 297 - Reviews 298 - </div> 299 - <Link href={`/profile/${handle}/houses`} style={{ textDecoration: 'none', color: 'inherit', opacity: 0.6, padding: '0.5rem 1rem' }}> 300 - Houses 301 - </Link> 302 - </div> 245 + <TabBar 246 + tabs={[ 247 + { label: 'Reviews' }, 248 + { label: 'Houses', href: `/profile/${handle}/houses` }, 249 + ]} 250 + /> 303 251 304 252 <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '2rem' }}> 305 253 <div class="score-item"> ··· 334 282 {renderStars(leastFavoriteScore)} ({leastFavoriteScore.toFixed(1)}) 335 283 </div> 336 284 )} 337 - </div> 338 - </div> 339 - <div class="score-item" style={{ gridColumn: 'span 2' }}> 340 - <div className="score-label">Favorite House</div> 341 - <div className="score-value" style={{ fontSize: '1.1rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 342 - {favoriteHouse} 343 285 </div> 344 286 </div> 345 287 </div>
+44
src/components/TabBar.tsx
··· 1 + import { Link } from 'wouter' 2 + 3 + export interface Tab { 4 + label: string 5 + href?: string // If provided, renders as Link. Otherwise, renders as active tab. 6 + onClick?: () => void // Optional click handler for non-link tabs 7 + } 8 + 9 + interface TabBarProps { 10 + tabs: Tab[] 11 + } 12 + 13 + export function TabBar({ tabs }: TabBarProps) { 14 + return ( 15 + <div className="tab-bar"> 16 + {tabs.map((tab, index) => { 17 + // Active tab (no href) or clickable tab 18 + if (!tab.href) { 19 + return ( 20 + <div 21 + key={index} 22 + className="tab-item tab-item--active" 23 + onClick={tab.onClick} 24 + style={{ cursor: tab.onClick ? 'pointer' : 'default' }} 25 + > 26 + {tab.label} 27 + </div> 28 + ) 29 + } 30 + 31 + // Inactive tab (with link) 32 + return ( 33 + <Link 34 + key={index} 35 + href={tab.href} 36 + className="tab-item" 37 + > 38 + {tab.label} 39 + </Link> 40 + ) 41 + })} 42 + </div> 43 + ) 44 + }