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

Add review data durability and post-24h editing

Schema Changes:
- Add optional fragranceName and houseName fields to review records
- Reviews now cache fragrance metadata for durability when fragrances are deleted/renamed
- Regenerated API types from updated lexicon schema

Fragrance Deletion:
- Enable deletion even when reviews exist (with warnings)
- Show review count and impact in deletion UI
- Reviews display deleted fragrances as "Name (deleted)"
- Created EditFragrancePage component with edit/delete functionality
- Added route for /profile/:handle/fragrance/:rkey/edit

Review Editing After 24 Hours:
- Allow editing text notes at any time
- Allow changing fragrance assignment at any time
- Lock stage ratings after 24 hours
- Update validation logic to distinguish editable vs locked fields
- Show edit button for all reviews with appropriate functionality

Bug Fixes:
- Fix fragrance deletion using typed method instead of raw XRPC call
- Fix review deletion using typed method instead of raw XRPC call
- Fix edit button visibility for reviews >= 24 hours old
- Remove "or is private" from fragrance not found error (records are always public)
- Update cached fragrance metadata when editing old reviews

UI/UX Improvements:
- Show cached fragrance names in reviews when fragrance is deleted
- Updated edit UI for post-24h reviews (text and fragrance only)
- Better confirmation dialogs explaining what will change
- Clearer error messages and permissions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+520 -49
+6 -2
src/app.tsx
··· 13 13 import { ProfileHousesPage } from './components/ProfileHousesPage' 14 14 import { ProfileFragrancesPage } from './components/ProfileFragrancesPage' 15 15 import { FragrancePage } from './components/FragrancePage' 16 + import { EditFragrancePage } from './components/EditFragrancePage' 16 17 import { SettingsPage } from './components/SettingsPage' 17 18 import { Header } from './components/Header' 18 19 import { Footer } from './components/Footer' ··· 210 211 <Route path="/profile/:handle/review/:rkey"> 211 212 {(params) => <SingleReviewPage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 212 213 </Route> 214 + <Route path="/profile/:handle/house/:rkey/edit"> 215 + {(params) => <EditHousePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 216 + </Route> 213 217 <Route path="/profile/:handle/house/:rkey"> 214 218 {(params) => <HousePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 215 219 </Route> 216 - <Route path="/profile/:handle/house/:rkey/edit"> 217 - {(params) => <EditHousePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 220 + <Route path="/profile/:handle/fragrance/:rkey/edit"> 221 + {(params) => <EditFragrancePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />} 218 222 </Route> 219 223 <Route path="/profile/:handle/fragrance/:rkey"> 220 224 {(params) => <FragrancePage handle={params.handle} rkey={params.rkey} session={session} userProfile={userProfile} onLogout={handleLogout} />}
+2 -2
src/client/index.ts
··· 6 6 type FetchHandler, 7 7 type FetchHandlerOptions, 8 8 } from '@atproto/xrpc' 9 + import { schemas } from './lexicons.js' 9 10 import { 11 + schemas as bskySchemas, 10 12 ComAtprotoRepoListRecords, 11 13 ComAtprotoRepoGetRecord, 12 14 ComAtprotoRepoCreateRecord, 13 15 ComAtprotoRepoPutRecord, 14 16 ComAtprotoRepoDeleteRecord, 15 17 } from '@atproto/api' 16 - import { schemas as bskySchemas } from '@atproto/api' 17 - import { schemas } from './lexicons.js' 18 18 import { type OmitKey, type Un$Typed } from './util.js' 19 19 import * as SocialDrydownFragrance from './types/social/drydown/fragrance.js' 20 20 import * as SocialDrydownHouse from './types/social/drydown/house.js'
+12
src/client/lexicons.ts
··· 105 105 format: 'at-uri', 106 106 description: 'Reference to the social.drydown.fragrance record', 107 107 }, 108 + fragranceName: { 109 + type: 'string', 110 + maxLength: 100, 111 + description: 112 + 'Cached fragrance name for durability when fragrance is deleted or renamed', 113 + }, 114 + houseName: { 115 + type: 'string', 116 + maxLength: 100, 117 + description: 118 + 'Cached house name for durability when house is deleted or renamed', 119 + }, 108 120 createdAt: { 109 121 type: 'string', 110 122 format: 'datetime',
+4
src/client/types/social/drydown/review.ts
··· 12 12 $type: 'social.drydown.review' 13 13 /** Reference to the social.drydown.fragrance record */ 14 14 fragrance: string 15 + /** Cached fragrance name for durability when fragrance is deleted or renamed */ 16 + fragranceName?: string 17 + /** Cached house name for durability when house is deleted or renamed */ 18 + houseName?: string 15 19 /** Timestamp when the review was created */ 16 20 createdAt: string 17 21 /** First Impression: How it smells immediately (1-5) */
+6
src/components/CreateReview.tsx
··· 196 196 197 197 setIsSubmitting(true) 198 198 try { 199 + // Find selected fragrance to cache metadata 200 + const selectedFragrance = fragrances.find(f => f.uri === selectedFragranceUri) 201 + const selectedHouse = selectedFragrance ? houses.find(h => h.uri === selectedFragrance.houseUri) : null 202 + 199 203 const reviewData = { 200 204 fragrance: selectedFragranceUri, 205 + fragranceName: selectedFragrance?.name, 206 + houseName: selectedHouse?.name, 201 207 openingRating: openingRating, 202 208 openingProjection: openingProjection, 203 209 createdAt: new Date().toISOString()
+331
src/components/EditFragrancePage.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 { AppDisclaimer } from './AppDisclaimer' 6 + import { Header } from './Header' 7 + import { Footer } from './Footer' 8 + import { Combobox } from './Combobox' 9 + import { resolveIdentity } from '../utils/resolveIdentity' 10 + import { Button } from './Button' 11 + 12 + const MICROCOSM_API = "https://ufos-api.microcosm.blue" 13 + 14 + interface EditFragrancePageProps { 15 + handle: string 16 + rkey: string 17 + session: OAuthSession | null 18 + userProfile?: { displayName?: string; handle: string } | null 19 + onLogout?: () => void 20 + } 21 + 22 + export function EditFragrancePage({ handle, rkey, session, userProfile, onLogout }: EditFragrancePageProps) { 23 + const [, setLocation] = useLocation() 24 + const [fragranceName, setFragranceName] = useState('') 25 + const [year, setYear] = useState<number | undefined>(undefined) 26 + const [selectedHouseUri, setSelectedHouseUri] = useState<string>('') 27 + const [isLoading, setIsLoading] = useState(true) 28 + const [isSubmitting, setIsSubmitting] = useState(false) 29 + const [error, setError] = useState<string | null>(null) 30 + const [allHouses, setAllHouses] = useState<Array<{ uri: string; name: string; ownerHandle: string; avatar?: string }>>([]) 31 + const [reviewCount, setReviewCount] = useState(0) 32 + 33 + useEffect(() => { 34 + async function loadFragrance() { 35 + if (!session) { 36 + setLocation('/') 37 + return 38 + } 39 + 40 + try { 41 + setIsLoading(true) 42 + const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 43 + const res = await baseClient.social.drydown.fragrance.get({ 44 + repo: session.sub, 45 + rkey: rkey 46 + }) 47 + 48 + if (res.value) { 49 + setFragranceName(res.value.name as string || '') 50 + setYear(res.value.year as number | undefined) 51 + setSelectedHouseUri(res.value.house as string || '') 52 + } else { 53 + setError("Fragrance not found or invalid format") 54 + } 55 + 56 + // Fetch ALL houses globally for selection (from Microcosm) 57 + const housesRes = await fetch(`${MICROCOSM_API}/records?collection=social.drydown.house&limit=2000`) 58 + if (housesRes.ok) { 59 + const allHousesData = await housesRes.json() 60 + 61 + // Resolve handles and avatars for each house owner 62 + const housesWithHandles = await Promise.all( 63 + allHousesData 64 + .filter((h: any) => h.record?.name) 65 + .map(async (h: any) => { 66 + try { 67 + const { profileData } = await resolveIdentity(h.did) 68 + return { 69 + uri: `at://${h.did}/${h.collection}/${h.rkey}`, 70 + name: h.record.name, 71 + ownerHandle: profileData.handle, 72 + avatar: profileData.avatar 73 + } 74 + } catch (e) { 75 + return { 76 + uri: `at://${h.did}/${h.collection}/${h.rkey}`, 77 + name: h.record.name, 78 + ownerHandle: h.did, 79 + avatar: undefined 80 + } 81 + } 82 + }) 83 + ) 84 + 85 + setAllHouses(housesWithHandles) 86 + } 87 + 88 + // Fetch all reviews globally to check if this fragrance is referenced 89 + const fragranceUri = `at://${session.sub}/social.drydown.fragrance/${rkey}` 90 + const reviewsRes = await fetch(`${MICROCOSM_API}/records?collection=social.drydown.review&limit=2000`) 91 + if (reviewsRes.ok) { 92 + const allReviews = await reviewsRes.json() 93 + const fragranceReviews = allReviews.filter((r: any) => 94 + r.record && r.record.fragrance === fragranceUri 95 + ) 96 + setReviewCount(fragranceReviews.length) 97 + } 98 + } catch (err) { 99 + console.error('Failed to load fragrance', err) 100 + setError('Failed to load fragrance data. Please try again.') 101 + } finally { 102 + setIsLoading(false) 103 + } 104 + } 105 + 106 + loadFragrance() 107 + }, [rkey, session, setLocation]) 108 + 109 + const handleSubmit = async (e: Event) => { 110 + e.preventDefault() 111 + if (!session || !fragranceName.trim()) return 112 + 113 + setIsSubmitting(true) 114 + setError(null) 115 + 116 + try { 117 + const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 118 + 119 + // Fetch the existing record to get current data 120 + const currentRes = await baseClient.social.drydown.fragrance.get({ 121 + repo: session.sub, 122 + rkey: rkey 123 + }) 124 + 125 + const existingRecord = currentRes.value as any 126 + 127 + // Build updated record 128 + const updatedRecord: any = { 129 + ...existingRecord, 130 + name: fragranceName.trim() 131 + } 132 + 133 + // Update year (only if provided, otherwise remove it) 134 + if (year && year > 0) { 135 + updatedRecord.year = year 136 + } else { 137 + delete updatedRecord.year 138 + } 139 + 140 + // Update house (only if selected) 141 + if (selectedHouseUri) { 142 + updatedRecord.house = selectedHouseUri 143 + } else { 144 + delete updatedRecord.house 145 + } 146 + 147 + await baseClient.social.drydown.fragrance.put({ 148 + repo: session.sub, 149 + rkey 150 + }, updatedRecord) 151 + 152 + // Redirect back to fragrance page on success 153 + setLocation(`/profile/${handle}/fragrance/${rkey}`) 154 + } catch (err) { 155 + console.error('Failed to update fragrance', err) 156 + setError('Failed to update fragrance. Please try again.') 157 + setIsSubmitting(false) 158 + } 159 + } 160 + 161 + async function handleDelete() { 162 + if (!session) return 163 + 164 + // Build confirmation message 165 + let confirmMessage = `Delete "${fragranceName}"?\n\nThis action cannot be undone.` 166 + 167 + if (reviewCount > 0) { 168 + confirmMessage += `\n\n⚠️ WARNING: This fragrance has ${reviewCount} review${reviewCount !== 1 ? 's' : ''} from users across the network.\n\nDeleted fragrances will show as "${fragranceName} (deleted)" in existing reviews. Users can edit their reviews to reassign them to a different fragrance.` 169 + } 170 + 171 + const confirmed = window.confirm(confirmMessage) 172 + if (!confirmed) return 173 + 174 + try { 175 + const baseClient = new AtpBaseClient(session.fetchHandler.bind(session)) 176 + await baseClient.social.drydown.fragrance.delete({ 177 + repo: session.sub, 178 + rkey: rkey 179 + }) 180 + 181 + alert('Fragrance deleted successfully') 182 + setLocation(`/profile/${handle}/fragrances`) 183 + } catch (e) { 184 + console.error('Failed to delete fragrance', e) 185 + const errorMsg = e instanceof Error ? e.message : 'Unknown error' 186 + setError(`Failed to delete fragrance: ${errorMsg}`) 187 + } 188 + } 189 + 190 + if (isLoading) return <div className="page-container">Loading Fragrance...</div> 191 + 192 + if (error) return ( 193 + <div className="page-container"> 194 + <div className="error">{error}</div> 195 + <Button onClick={() => setLocation(`/profile/${handle}/fragrance/${rkey}`)} style={{ marginTop: '1rem' }}> 196 + Back to Fragrance 197 + </Button> 198 + </div> 199 + ) 200 + 201 + return ( 202 + <div className="page-container"> 203 + <Header session={session} userProfile={userProfile} onLogout={onLogout} /> 204 + 205 + <h2>Edit Fragrance</h2> 206 + 207 + <form onSubmit={handleSubmit} style={{ maxWidth: '400px' }}> 208 + <div style={{ marginBottom: '1rem' }}> 209 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 210 + Fragrance Name 211 + </label> 212 + <input 213 + type="text" 214 + value={fragranceName} 215 + onInput={(e) => setFragranceName((e.target as HTMLInputElement).value)} 216 + style={{ width: '100%', padding: '0.5rem' }} 217 + required 218 + maxLength={100} 219 + disabled={isSubmitting} 220 + /> 221 + </div> 222 + 223 + <div style={{ marginBottom: '1rem' }}> 224 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 225 + Year (Optional) 226 + </label> 227 + <input 228 + type="number" 229 + value={year || ''} 230 + onInput={(e) => { 231 + const val = (e.target as HTMLInputElement).value 232 + setYear(val ? parseInt(val, 10) : undefined) 233 + }} 234 + style={{ width: '100%', padding: '0.5rem' }} 235 + min={1800} 236 + max={new Date().getFullYear() + 1} 237 + placeholder="e.g., 2020" 238 + disabled={isSubmitting} 239 + /> 240 + </div> 241 + 242 + <div style={{ marginBottom: '1rem' }}> 243 + <Combobox 244 + label="House (Optional)" 245 + placeholder="Search houses..." 246 + items={allHouses.map(h => ({ 247 + label: h.name, 248 + value: h.uri, 249 + avatar: h.avatar, 250 + subtitle: `@${h.ownerHandle}` 251 + }))} 252 + onSelect={(uri) => setSelectedHouseUri(uri)} 253 + /> 254 + {selectedHouseUri && ( 255 + <div style={{ marginTop: '0.5rem', fontSize: '0.9rem', opacity: 0.7 }}> 256 + Currently: {allHouses.find(h => h.uri === selectedHouseUri)?.name || 'Unknown House'} 257 + </div> 258 + )} 259 + </div> 260 + 261 + <div style={{ display: 'flex', gap: '1rem' }}> 262 + <Button 263 + type="submit" 264 + disabled={isSubmitting || !fragranceName.trim()} 265 + > 266 + {isSubmitting ? 'Saving...' : 'Save Changes'} 267 + </Button> 268 + <Button 269 + emphasis="muted" 270 + size="sm" 271 + onClick={() => setLocation(`/profile/${handle}/fragrance/${rkey}`)} 272 + disabled={isSubmitting} 273 + > 274 + Cancel 275 + </Button> 276 + </div> 277 + </form> 278 + 279 + {/* Danger Zone */} 280 + <div style={{ 281 + marginTop: '3rem', 282 + padding: '1.5rem', 283 + border: '2px solid #dc2626', 284 + borderRadius: '8px', 285 + maxWidth: '600px' 286 + }}> 287 + <h3 style={{ marginTop: 0, color: '#dc2626', fontSize: '1.1rem' }}>Danger Zone</h3> 288 + 289 + {reviewCount > 0 ? ( 290 + <> 291 + <p style={{ fontSize: '0.9rem', color: '#666', marginBottom: '0.5rem' }}> 292 + <strong>⚠️ Warning:</strong> This fragrance has <strong>{reviewCount} review{reviewCount !== 1 ? 's' : ''}</strong> from users across the network. 293 + </p> 294 + <p style={{ fontSize: '0.85rem', color: '#999', marginBottom: '1rem' }}> 295 + Deleting this fragrance will cause existing reviews to show it as "<strong>{fragranceName} (deleted)</strong>". 296 + Users will be able to edit their reviews to reassign them to a different fragrance. 297 + The cached fragrance name in existing reviews ensures users know what they reviewed. 298 + </p> 299 + <Button 300 + emphasis="muted" 301 + size="sm" 302 + className="btn--danger" 303 + onClick={handleDelete} 304 + context="destructive" 305 + > 306 + Delete Fragrance ({reviewCount} review{reviewCount !== 1 ? 's' : ''} will be affected) 307 + </Button> 308 + </> 309 + ) : ( 310 + <> 311 + <p style={{ fontSize: '0.9rem', color: '#666', marginBottom: '1rem' }}> 312 + This fragrance has no reviews. You can safely delete it. 313 + </p> 314 + <Button 315 + emphasis="muted" 316 + size="sm" 317 + className="btn--danger" 318 + onClick={handleDelete} 319 + context="destructive" 320 + > 321 + Delete Fragrance 322 + </Button> 323 + </> 324 + )} 325 + </div> 326 + 327 + <AppDisclaimer variant="minimal" /> 328 + <Footer session={session} /> 329 + </div> 330 + ) 331 + }
+108 -30
src/components/EditReview.tsx
··· 8 8 import { RubricDisplay } from './RubricDisplay' 9 9 import type { OAuthSession } from '@atproto/oauth-client-browser' 10 10 import { WeatherService, type WeatherData } from '../services/weatherService' 11 - import { validateEditPermissions, validateStageUpdate, canChangeFragrance } from '../utils/reviewValidation' 12 - import { updateReviewFragrance } from '../api/reviews' 11 + import { validateEditPermissions, validateStageUpdate } from '../utils/reviewValidation' 13 12 import { Combobox } from './Combobox' 14 13 import { resolveIdentity } from '../utils/resolveIdentity' 15 14 import { NotificationService } from '../services/notificationService' ··· 81 80 repo: session.sub, 82 81 rkey 83 82 }) 84 - const reviewValue = reviewData.value 83 + let reviewValue = reviewData.value 84 + 85 + // Populate fragranceName and houseName if missing (for old reviews) 86 + if (!reviewValue.fragranceName && reviewValue.fragrance) { 87 + try { 88 + const fragranceData = await resolveAtUri(reviewValue.fragrance) 89 + if (fragranceData) { 90 + reviewValue.fragranceName = fragranceData.name 91 + 92 + // Also fetch house name if fragrance has a house 93 + if (fragranceData.house) { 94 + const houseData = await resolveAtUri(fragranceData.house) 95 + if (houseData) { 96 + reviewValue.houseName = houseData.name 97 + } 98 + } 99 + } 100 + } catch (e) { 101 + console.error('Failed to populate fragrance metadata', e) 102 + } 103 + } 104 + 85 105 setReview(reviewValue) 86 106 87 107 // COMPUTE WHICH STAGE TO EDIT BASED ON PERMISSIONS 88 108 const permissions = validateEditPermissions(reviewValue) 89 109 90 - // After 24 hours, only allow fragrance migration 91 - if (permissions.error && canChangeFragrance(reviewValue)) { 92 - setShowFragranceMigrationOnly(true) 93 - } else if (permissions.error) { 94 - alert(permissions.error) 95 - onCancel() 96 - return 110 + // After 24 hours, only allow text and fragrance editing 111 + if (permissions.error) { 112 + // Check if we can still edit text/fragrance 113 + if (permissions.canEditText || permissions.canChangeFragrance) { 114 + setShowFragranceMigrationOnly(true) 115 + } else { 116 + alert(permissions.error) 117 + onCancel() 118 + return 119 + } 97 120 } 98 121 99 122 // Determine edit stage: prefer adding new stage over editing existing, fall back to Stage 1 ··· 104 127 } else if (permissions.canEditStage1) { 105 128 setEditStage('stage1') 106 129 } else { 107 - // Shouldn't happen within 24 hours, but just in case 130 + // After 24 hours - default to stage1 view (ratings disabled) 108 131 setEditStage('stage1') 109 132 } 110 133 ··· 306 329 } 307 330 308 331 async function handleFragranceMigration() { 309 - if (!atp || !review || !selectedFragranceUri || selectedFragranceUri === review.fragrance) return 332 + if (!atp || !review) return 310 333 311 - const targetFragrance = userFragrances.find(f => f.uri === selectedFragranceUri) 312 - if (!targetFragrance) return 334 + const hasFragranceChange = selectedFragranceUri && selectedFragranceUri !== review.fragrance 335 + const hasTextChange = text !== (review.text || '') 313 336 314 - const confirmed = window.confirm( 315 - `Reconnect this review from "${fragranceName}" to "${targetFragrance.name}"?\n\nThis action cannot be undone.` 316 - ) 337 + // Check if there are any changes to save 338 + if (!hasFragranceChange && !hasTextChange) { 339 + alert('No changes to save') 340 + return 341 + } 342 + 343 + const targetFragrance = hasFragranceChange ? userFragrances.find(f => f.uri === selectedFragranceUri) : null 344 + 345 + // Build confirmation message 346 + let confirmMessage = 'Save changes?\n\n' 347 + if (hasTextChange) { 348 + confirmMessage += '• Text notes will be updated\n' 349 + } 350 + if (hasFragranceChange && targetFragrance) { 351 + confirmMessage += `• Reconnect from "${fragranceName}" to "${targetFragrance.name}"\n` 352 + } 353 + confirmMessage += '\nThis action cannot be undone.' 354 + 355 + const confirmed = window.confirm(confirmMessage) 317 356 if (!confirmed) return 318 357 319 358 try { 320 359 setIsSubmitting(true) 321 - await updateReviewFragrance(atp, session.sub, reviewUri, selectedFragranceUri) 360 + const rkey = reviewUri.split('/').pop()! 361 + 362 + // Update the review with text changes 363 + const updatedReview = { ...review, text } 364 + 365 + // If fragrance is being changed, update it 366 + if (hasFragranceChange) { 367 + updatedReview.fragrance = selectedFragranceUri 368 + 369 + // Update cached fragrance name/house if available 370 + if (targetFragrance) { 371 + updatedReview.fragranceName = targetFragrance.name 372 + updatedReview.houseName = targetFragrance.houseName 373 + } 374 + } 375 + 376 + await atp.social.drydown.review.put( 377 + { repo: session.sub, rkey }, 378 + updatedReview 379 + ) 322 380 323 - // Navigate to new fragrance page 324 - const fragranceRkey = selectedFragranceUri.split('/').pop()! 325 - setLocation(`/profile/${userHandle}/fragrance/${fragranceRkey}`) 381 + // Navigate appropriately 382 + if (hasFragranceChange && targetFragrance) { 383 + const fragranceRkey = selectedFragranceUri!.split('/').pop()! 384 + setLocation(`/profile/${userHandle}/fragrance/${fragranceRkey}`) 385 + } else { 386 + onSuccess() 387 + } 326 388 } catch (e) { 327 - console.error('Failed to migrate review', e) 389 + console.error('Failed to save review changes', e) 328 390 const errorMsg = e instanceof Error ? e.message : 'Unknown error' 329 - alert(`Failed to reconnect review: ${errorMsg}`) 391 + alert(`Failed to save changes: ${errorMsg}`) 330 392 } finally { 331 393 setIsSubmitting(false) 332 394 } ··· 394 456 395 457 if (isLoading) return <div>Loading...</div> 396 458 397 - // Show fragrance migration UI only (past 24 hours) 459 + // Show text and fragrance migration UI only (past 24 hours) 398 460 if (showFragranceMigrationOnly) { 399 461 return ( 400 462 <div> 401 - <h2>Reconnect Review</h2> 463 + <h2>Edit Review</h2> 402 464 <p style={{ marginBottom: '2rem', color: '#666' }}> 403 - This review is locked for editing after 24 hours, but you can 404 - reconnect it to a different fragrance. 465 + Stage ratings are locked after 24 hours, but you can edit your text notes 466 + and reconnect this review to a different fragrance. 405 467 </p> 406 468 407 469 <div style={{ marginBottom: '2rem' }}> 470 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}> 471 + Written Review (Optional) 472 + </label> 473 + <textarea 474 + value={text} 475 + onInput={(e) => setText((e.target as HTMLInputElement).value)} 476 + style={{ width: '100%', padding: '0.5rem', minHeight: '100px' }} 477 + maxLength={255} 478 + placeholder="Share your thoughts..." 479 + /> 480 + <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}> 481 + {text.length} / 255 characters 482 + </div> 483 + </div> 484 + 485 + <div style={{ marginBottom: '2rem' }}> 408 486 <Combobox 409 - label="Select New Fragrance" 487 + label="Change Fragrance (Optional)" 410 488 placeholder="Search fragrances..." 411 489 items={userFragrances.map(f => ({ 412 490 label: f.name, ··· 424 502 425 503 <Button 426 504 onClick={handleFragranceMigration} 427 - disabled={isSubmitting || !selectedFragranceUri || selectedFragranceUri === review?.fragrance} 505 + disabled={isSubmitting} 428 506 context="save" 429 507 > 430 - {isSubmitting ? 'Reconnecting...' : 'Reconnect Review'} 508 + {isSubmitting ? 'Saving...' : 'Save Changes'} 431 509 </Button> 432 510 <Button emphasis="muted" size="sm" onClick={onCancel} style={{ marginLeft: '1rem' }} context="cancel"> 433 511 cancel
+1 -1
src/components/FragrancePage.tsx
··· 68 68 const fragranceData = await resolveAtUri(fragranceUri) 69 69 70 70 if (!fragranceData) { 71 - setError("Fragrance not found or is private") 71 + setError("Fragrance not found") 72 72 setIsLoading(false) 73 73 return 74 74 }
+20 -4
src/components/ReviewList.tsx
··· 42 42 localStorage.setItem('drydown_review_sort_order', sortOrder) 43 43 }, [sortBy, sortOrder]) 44 44 45 - const getFragranceInfo = (fragranceUri: string) => { 46 - return fragrances.get(fragranceUri) || { name: 'Unknown Fragrance' } 45 + const getFragranceInfo = (fragranceUri: string, review: any): FragranceInfo => { 46 + const fragInfo = fragrances.get(fragranceUri) 47 + 48 + // If fragrance exists in map, use it 49 + if (fragInfo) { 50 + return fragInfo 51 + } 52 + 53 + // Fragrance not found - check if review has cached metadata 54 + if (review.fragranceName) { 55 + return { 56 + name: `${review.fragranceName} (deleted)`, 57 + houseName: review.houseName 58 + } 59 + } 60 + 61 + // No cached data - completely unknown 62 + return { name: 'Unknown Fragrance' } 47 63 } 48 64 49 65 const getAuthor = (reviewUri: string): AuthorInfo | undefined => { ··· 71 87 {active.length > 0 && ( 72 88 <section className="review-section" style={{ marginBottom: '1rem' }}> 73 89 {active.map(review => { 74 - const fragInfo = getFragranceInfo(review.value.fragrance) 90 + const fragInfo = getFragranceInfo(review.value.fragrance, review.value) 75 91 return ( 76 92 <ReviewCard 77 93 key={review.uri} ··· 108 124 </div> 109 125 </div> 110 126 {sortedPast.map(review => { 111 - const fragInfo = getFragranceInfo(review.value.fragrance) 127 + const fragInfo = getFragranceInfo(review.value.fragrance, review.value) 112 128 return ( 113 129 <ReviewCard 114 130 key={review.uri}
+2 -3
src/components/SingleReviewPage.tsx
··· 284 284 285 285 const handleDelete = async () => { 286 286 if (!session) return 287 - 287 + 288 288 const confirmed = window.confirm("Are you sure you want to delete this review? This action cannot be undone.") 289 289 if (!confirmed) return 290 290 291 291 try { 292 292 const client = new AtpBaseClient(session.fetchHandler.bind(session)) 293 - await client.call('com.atproto.repo.deleteRecord', { 293 + await client.social.drydown.review.delete({ 294 294 repo: session.sub, 295 - collection: 'social.drydown.review', 296 295 rkey: rkey 297 296 }) 298 297 setLocation('/')
+10
src/lexicons/social.drydown.review.json
··· 15 15 "format": "at-uri", 16 16 "description": "Reference to the social.drydown.fragrance record" 17 17 }, 18 + "fragranceName": { 19 + "type": "string", 20 + "maxLength": 100, 21 + "description": "Cached fragrance name for durability when fragrance is deleted or renamed" 22 + }, 23 + "houseName": { 24 + "type": "string", 25 + "maxLength": 100, 26 + "description": "Cached house name for durability when house is deleted or renamed" 27 + }, 18 28 "createdAt": { 19 29 "type": "string", 20 30 "format": "datetime",
+5 -4
src/utils/reviewUtils.ts
··· 222 222 } 223 223 224 224 export function getReviewActionState(reviewValue: any): { action: 'stage2' | 'stage3' | null, hint: string | null } { 225 - if (!isReviewEditable(reviewValue)) { 226 - return { action: null, hint: null } 225 + const elapsed = calculateElapsedHours(reviewValue.createdAt) 226 + 227 + // After 24 hours, can only edit text and fragrance 228 + if (elapsed >= 24) { 229 + return { action: null, hint: 'Edit text/fragrance' } 227 230 } 228 - 229 - const elapsed = calculateElapsedHours(reviewValue.createdAt) 230 231 231 232 // Stage 3 logic (end rating) 232 233 if (reviewValue.drydownRating && !reviewValue.endRating) {
+13 -3
src/utils/reviewValidation.ts
··· 6 6 canEditStage3: boolean 7 7 canAddStage2: boolean 8 8 canAddStage3: boolean 9 + canEditText: boolean 10 + canChangeFragrance: boolean 9 11 error?: string 10 12 } 11 13 ··· 15 17 export function validateEditPermissions(review: any): EditValidationResult { 16 18 const elapsed = calculateElapsedHours(review.createdAt) 17 19 18 - // After 24 hours, nothing can be edited 20 + // Text and fragrance can ALWAYS be edited 21 + const canEditText = true 22 + const canChangeFragrance = true 23 + 24 + // After 24 hours, ratings/stages cannot be edited 19 25 if (elapsed >= 24) { 20 26 return { 21 27 canEditStage1: false, ··· 23 29 canEditStage3: false, 24 30 canAddStage2: false, 25 31 canAddStage3: false, 26 - error: 'Reviews cannot be edited after 24 hours' 32 + canEditText, 33 + canChangeFragrance, 34 + error: 'Stage ratings cannot be edited after 24 hours' 27 35 } 28 36 } 29 37 ··· 47 55 canEditStage2, 48 56 canEditStage3, 49 57 canAddStage2, 50 - canAddStage3 58 + canAddStage3, 59 + canEditText, 60 + canChangeFragrance 51 61 } 52 62 } 53 63