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

Add Combobox and review creation flow

+409 -2
+16 -1
src/app.tsx
··· 4 4 import './app.css' 5 5 import { initAuth, logout } from './auth' 6 6 import { LoginForm } from './components/LoginForm' 7 + import { CreateReview } from './components/CreateReview' 7 8 import type { OAuthSession } from '@atproto/oauth-client-browser' 8 9 9 10 export function App() { 10 11 const [session, setSession] = useState<OAuthSession | null>(null) 11 12 const [isInitializing, setIsInitializing] = useState(true) 13 + const [view, setView] = useState<'home' | 'create-review'>('home') 12 14 13 15 useEffect(() => { 14 16 const initialize = async () => { ··· 44 46 try { 45 47 await logout(session.sub) 46 48 setSession(null) 49 + setView('home') 47 50 } catch (err) { 48 51 console.error('Logout failed:', err) 49 52 } ··· 71 74 <div class="card"> 72 75 <h2>Welcome, {session.sub}!</h2> 73 76 <p>You are now signed in via OAuth.</p> 74 - <button onClick={handleLogout}>Sign Out</button> 77 + 78 + {view === 'home' ? ( 79 + <div style={{ display: 'flex', gap: '1rem', flexDirection: 'column', alignItems: 'center' }}> 80 + <button onClick={() => setView('create-review')}>Create New Review</button> 81 + <button onClick={handleLogout}>Sign Out</button> 82 + </div> 83 + ) : ( 84 + <CreateReview 85 + session={session} 86 + onCancel={() => setView('home')} 87 + onSuccess={() => setView('home')} 88 + /> 89 + )} 75 90 </div> 76 91 )} 77 92
+1
src/auth.ts
··· 26 26 client = new BrowserOAuthClient({ 27 27 handleResolver: 'https://bsky.social', 28 28 clientMetadata, 29 + fetch: window.fetch.bind(window), // Fix for "Illegal invocation" in Safari/Strict mode 29 30 }) 30 31 return client 31 32 } catch (err) {
+3 -1
src/client/index.ts
··· 17 17 export * as SocialDrydownHouse from './types/social/drydown/house.js' 18 18 export * as SocialDrydownReview from './types/social/drydown/review.js' 19 19 20 + import { schemas as bskySchemas } from '@atproto/api' 21 + 20 22 export class AtpBaseClient extends XrpcClient { 21 23 social: SocialNS 22 24 23 25 constructor(options: FetchHandler | FetchHandlerOptions) { 24 - super(options, schemas) 26 + super(options, [...schemas, ...bskySchemas]) 25 27 this.social = new SocialNS(this) 26 28 } 27 29
+63
src/components/Combobox.css
··· 1 + .combobox-wrapper { 2 + position: relative; 3 + width: 100%; 4 + margin-bottom: 1rem; 5 + } 6 + 7 + .combobox-label { 8 + display: block; 9 + margin-bottom: 0.5rem; 10 + font-weight: bold; 11 + } 12 + 13 + .combobox-input { 14 + width: 100%; 15 + padding: 0.5rem; 16 + border: 1px solid #ccc; 17 + border-radius: 4px; 18 + font-size: 1rem; 19 + } 20 + 21 + .combobox-dropdown { 22 + position: absolute; 23 + top: 100%; 24 + left: 0; 25 + right: 0; 26 + border: 1px solid #ccc; 27 + border-top: none; 28 + border-radius: 0 0 4px 4px; 29 + background: var(--bg-color, #fff); /* Fallback to white if var not set */ 30 + color: var(--text-color, #000); 31 + list-style: none; 32 + padding: 0; 33 + margin: 0; 34 + z-index: 1000; 35 + max-height: 200px; 36 + overflow-y: auto; 37 + box-shadow: 0 4px 6px rgba(0,0,0,0.1); 38 + } 39 + 40 + .combobox-item { 41 + padding: 0.5rem; 42 + cursor: pointer; 43 + } 44 + 45 + .combobox-item:hover { 46 + background-color: #f0f0f0; 47 + color: #000; 48 + } 49 + 50 + .combobox-item.disabled { 51 + color: #999; 52 + cursor: default; 53 + } 54 + 55 + .combobox-item.disabled:hover { 56 + background-color: transparent; 57 + } 58 + 59 + .combobox-item.create-option { 60 + font-style: italic; 61 + color: #0066cc; 62 + border-top: 1px dashed #ccc; 63 + }
+100
src/components/Combobox.tsx
··· 1 + import { useState, useEffect, useRef } from 'preact/hooks' 2 + import './Combobox.css' 3 + 4 + interface ComboboxProps { 5 + label: string 6 + placeholder?: string 7 + items: { label: string; value: string }[] 8 + onSelect: (value: string) => void 9 + onCreate?: (value: string) => void 10 + } 11 + 12 + export function Combobox({ label, placeholder, items, onSelect, onCreate }: ComboboxProps) { 13 + const [isOpen, setIsOpen] = useState(false) 14 + const [inputValue, setInputValue] = useState('') 15 + const [filteredItems, setFilteredItems] = useState(items) 16 + const wrapperRef = useRef<HTMLDivElement>(null) 17 + 18 + useEffect(() => { 19 + // Filter items based on input 20 + const lowerInput = inputValue.toLowerCase() 21 + const filtered = items.filter(item => 22 + item.label.toLowerCase().includes(lowerInput) 23 + ) 24 + setFilteredItems(filtered) 25 + }, [inputValue, items]) 26 + 27 + // Close dropdown when clicking outside 28 + useEffect(() => { 29 + function handleClickOutside(event: MouseEvent) { 30 + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { 31 + setIsOpen(false) 32 + } 33 + } 34 + document.addEventListener('mousedown', handleClickOutside) 35 + return () => document.removeEventListener('mousedown', handleClickOutside) 36 + }, []) 37 + 38 + const handleInputChange = (e: Event) => { 39 + const value = (e.target as HTMLInputElement).value 40 + setInputValue(value) 41 + setIsOpen(true) 42 + 43 + // If exact match, select it (optional, but good for UX) 44 + // For now we rely on explicit selection 45 + } 46 + 47 + const handleSelect = (item: { label: string; value: string }) => { 48 + setInputValue(item.label) 49 + onSelect(item.value) 50 + setIsOpen(false) 51 + } 52 + 53 + const handleCreate = () => { 54 + if (onCreate) { 55 + onCreate(inputValue) 56 + // We assume the parent will handle updating the items list and selecting the new item 57 + setIsOpen(false) 58 + } 59 + } 60 + 61 + const showCreateOption = onCreate && inputValue && !items.some(i => i.label.toLowerCase() === inputValue.toLowerCase()) 62 + 63 + return ( 64 + <div class="combobox-wrapper" ref={wrapperRef}> 65 + <label class="combobox-label">{label}</label> 66 + <input 67 + type="text" 68 + class="combobox-input" 69 + placeholder={placeholder} 70 + value={inputValue} 71 + onInput={handleInputChange} 72 + onFocus={() => setIsOpen(true)} 73 + /> 74 + {isOpen && ( 75 + <ul class="combobox-dropdown"> 76 + {filteredItems.map(item => ( 77 + <li 78 + key={item.value} 79 + class="combobox-item" 80 + onClick={() => handleSelect(item)} 81 + > 82 + {item.label} 83 + </li> 84 + ))} 85 + {filteredItems.length === 0 && !showCreateOption && ( 86 + <li class="combobox-item disabled">No results found</li> 87 + )} 88 + {showCreateOption && ( 89 + <li 90 + class="combobox-item create-option" 91 + onClick={handleCreate} 92 + > 93 + Create "{inputValue}"? 94 + </li> 95 + )} 96 + </ul> 97 + )} 98 + </div> 99 + ) 100 + }
+226
src/components/CreateReview.tsx
··· 1 + import { useState, useEffect } from 'preact/hooks' 2 + import { AtpBaseClient } from '../client/index' 3 + import { Combobox } from './Combobox' 4 + import type { OAuthSession } from '@atproto/oauth-client-browser' 5 + 6 + // Define local interfaces based on the lexicon types for easier usage 7 + interface House { 8 + uri: string 9 + name: string 10 + } 11 + 12 + interface Fragrance { 13 + uri: string 14 + name: string 15 + houseUri: string 16 + } 17 + 18 + interface CreateReviewProps { 19 + session: OAuthSession 20 + onCancel: () => void 21 + onSuccess: () => void 22 + } 23 + 24 + export function CreateReview({ session, onCancel, onSuccess }: CreateReviewProps) { 25 + const [houses, setHouses] = useState<House[]>([]) 26 + const [fragrances, setFragrances] = useState<Fragrance[]>([]) 27 + 28 + // Selection state 29 + const [selectedHouseUri, setSelectedHouseUri] = useState<string>('') 30 + const [selectedFragranceUri, setSelectedFragranceUri] = useState<string>('') 31 + 32 + // Review state 33 + const [rating, setRating] = useState<number>(0) 34 + const [text, setText] = useState('') 35 + const [isSubmitting, setIsSubmitting] = useState(false) 36 + 37 + // Client 38 + const [atp, setAtp] = useState<AtpBaseClient | null>(null) 39 + 40 + useEffect(() => { 41 + async function initClient() { 42 + console.log("OAuth Session:", session) // DEBUG 43 + 44 + // Use the session's built-in fetchHandler for authenticated requests 45 + // The fetchHandler automatically routes to the user's PDS using tokenSet.aud 46 + // This object matches the SessionManager interface expected by XrpcClient 47 + const baseClient = new AtpBaseClient({ 48 + did: session.did, 49 + fetchHandler: (url, init) => session.fetchHandler(url, init) 50 + }) 51 + 52 + setAtp(baseClient) 53 + loadData(baseClient) 54 + } 55 + initClient() 56 + }, [session]) 57 + 58 + const loadData = async (client: AtpBaseClient) => { 59 + try { 60 + // Fetch Houses 61 + // Note: This only fetches the user's records. If we want global records, we'd need an AppView. 62 + // For now, assuming we are building a personal review system or reading from the user's repo. 63 + const houseRecords = await client.social.drydown.house.list({ repo: session.sub }) 64 + const mappedHouses = houseRecords.records.map(r => ({ 65 + uri: r.uri, 66 + name: r.value.name 67 + })) 68 + setHouses(mappedHouses) 69 + 70 + // Fetch Fragrances 71 + const fragranceRecords = await client.social.drydown.fragrance.list({ repo: session.sub }) 72 + const mappedFragrances = fragranceRecords.records.map(r => ({ 73 + uri: r.uri, 74 + name: r.value.name, 75 + houseUri: r.value.house 76 + })) 77 + setFragrances(mappedFragrances) 78 + 79 + } catch (e) { 80 + console.error("Failed to load data", e) 81 + } 82 + } 83 + 84 + const handleCreateHouse = async (name: string) => { 85 + if (!atp) return 86 + try { 87 + const res = await atp.social.drydown.house.create( 88 + { repo: session.sub }, 89 + { name, createdAt: new Date().toISOString() } 90 + ) 91 + console.log("Created house", res) 92 + const newHouse = { uri: res.uri, name } 93 + setHouses([...houses, newHouse]) 94 + setSelectedHouseUri(res.uri) 95 + } catch (e: any) { 96 + console.error("Failed to create house", e) 97 + alert(`Failed to create house: ${e.message || JSON.stringify(e)}`) 98 + } 99 + } 100 + 101 + const handleCreateFragrance = async (name: string) => { 102 + if (!atp || !selectedHouseUri) { 103 + alert("Please select a house first") 104 + return 105 + } 106 + try { 107 + const res = await atp.social.drydown.fragrance.create( 108 + { repo: session.sub }, 109 + { 110 + name, 111 + house: selectedHouseUri, 112 + createdAt: new Date().toISOString() 113 + } 114 + ) 115 + console.log("Created fragrance", res) 116 + const newFrag = { uri: res.uri, name, houseUri: selectedHouseUri } 117 + setFragrances([...fragrances, newFrag]) 118 + setSelectedFragranceUri(res.uri) 119 + } catch (e) { 120 + console.error("Failed to create fragrance", e) 121 + alert("Failed to create fragrance") 122 + } 123 + } 124 + 125 + const checkAuth = async () => { 126 + if (!atp) return 127 + try { 128 + console.log("Checking auth against:", (atp as any).service) 129 + // Check Auth using getTimeline (which requires auth and works on AppView) 130 + // getSession is PDS-only and fails on AppView. 131 + const res = await atp.call('app.bsky.feed.getTimeline', { limit: 1 }) 132 + console.log("Auth Check Success:", res) 133 + alert("Auth OK! Timeline fetched.") 134 + } catch (e: any) { 135 + console.error("Auth Check Failed", e) 136 + alert(`Auth Failed: ${e.message || e}`) 137 + } 138 + } 139 + 140 + const handleSubmit = async (e: Event) => { 141 + e.preventDefault() 142 + if (!atp || !selectedFragranceUri) return 143 + 144 + setIsSubmitting(true) 145 + try { 146 + await atp.social.drydown.review.create( 147 + { repo: session.sub }, 148 + { 149 + fragrance: selectedFragranceUri, 150 + overallRating: rating, 151 + text: text, 152 + createdAt: new Date().toISOString() 153 + } 154 + ) 155 + alert("Review created!") 156 + onSuccess() 157 + } catch (e) { 158 + console.error("Failed to submit review", e) 159 + alert("Failed to submit review") 160 + } finally { 161 + setIsSubmitting(false) 162 + } 163 + } 164 + 165 + // Filter fragrances by house if a house is selected 166 + const availableFragrances = selectedHouseUri 167 + ? fragrances.filter(f => f.houseUri === selectedHouseUri) 168 + : fragrances 169 + 170 + return ( 171 + <div class="create-review-container"> 172 + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> 173 + <h2>Create Review</h2> 174 + <button type="button" onClick={checkAuth} style={{ fontSize: '0.8rem' }}>Check Auth</button> 175 + </div> 176 + <button onClick={onCancel} style={{ marginBottom: '1rem' }}>Back</button> 177 + 178 + <form onSubmit={handleSubmit}> 179 + <Combobox 180 + label="House" 181 + placeholder="Search or add a House..." 182 + items={houses.map(h => ({ label: h.name, value: h.uri }))} 183 + onSelect={setSelectedHouseUri} 184 + onCreate={handleCreateHouse} 185 + /> 186 + {selectedHouseUri && <div style={{ fontSize: '0.8rem', color: 'green', marginBottom: '1rem'}}>House selected</div>} 187 + 188 + <Combobox 189 + label="Fragrance" 190 + placeholder="Search or add a Fragrance..." 191 + items={availableFragrances.map(f => ({ label: f.name, value: f.uri }))} 192 + onSelect={setSelectedFragranceUri} 193 + onCreate={handleCreateFragrance} 194 + /> 195 + {selectedFragranceUri && <div style={{ fontSize: '0.8rem', color: 'green', marginBottom: '1rem'}}>Fragrance selected</div>} 196 + 197 + <div style={{ marginBottom: '1rem' }}> 198 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>Rating (1-5)</label> 199 + <input 200 + type="number" 201 + min="1" 202 + max="5" 203 + value={rating} 204 + onInput={(e) => setRating(parseInt((e.target as HTMLInputElement).value))} 205 + style={{ width: '100%', padding: '0.5rem' }} 206 + /> 207 + </div> 208 + 209 + <div style={{ marginBottom: '1rem' }}> 210 + <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>Review Text</label> 211 + <textarea 212 + value={text} 213 + onInput={(e) => setText((e.target as HTMLInputElement).value)} 214 + style={{ width: '100%', padding: '0.5rem', minHeight: '100px' }} 215 + /> 216 + </div> 217 + 218 + <button type="submit" disabled={isSubmitting || !selectedFragranceUri}> 219 + {isSubmitting ? 'Submitting...' : 'Submit Review'} 220 + </button> 221 + </form> 222 + 223 + 224 + </div> 225 + ) 226 + }