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