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