Skip to content

Commit 5d37a27

Browse files
committed
Fully complete the wiring.
1 parent 63e9149 commit 5d37a27

File tree

7 files changed

+354
-330
lines changed

7 files changed

+354
-330
lines changed
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { Eye, EyeOff, Search, Settings, Trash2 } from 'lucide-react'
2+
import { useMemo, useState } from 'react'
3+
import { twMerge } from 'tailwind-merge'
4+
import Badge from '@/components/Badge'
5+
import { badgeCVA } from '@/components/design'
6+
import MultiSegment from '@/components/MultiSegment'
7+
import { allLeafValues, timeAgo } from '@/components/misc'
8+
import type { CommentTableRow } from '@/entrypoints/background'
9+
import type { FilterState } from '@/entrypoints/popup/popup'
10+
import { EnhancerRegistry } from '@/lib/registries'
11+
12+
const initialFilter: FilterState = {
13+
searchQuery: '',
14+
sentFilter: 'both',
15+
showTrashed: false,
16+
}
17+
18+
interface PopupRootProps {
19+
drafts: CommentTableRow[]
20+
}
21+
22+
export function PopupRoot({ drafts }: PopupRootProps) {
23+
const [selectedIds, setSelectedIds] = useState(new Set())
24+
const [filters, setFilters] = useState<FilterState>(initialFilter)
25+
26+
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
27+
setFilters((prev) => ({ ...prev, [key]: value }))
28+
}
29+
30+
const filteredDrafts = useMemo(() => {
31+
let filtered = [...drafts]
32+
if (!filters.showTrashed) {
33+
filtered = filtered.filter((d) => !d.isTrashed)
34+
}
35+
if (filters.sentFilter !== 'both') {
36+
filtered = filtered.filter((d) => (filters.sentFilter === 'sent' ? d.isSent : !d.isSent))
37+
}
38+
if (filters.searchQuery) {
39+
const query = filters.searchQuery.toLowerCase()
40+
filtered = filtered.filter((d) => {
41+
for (const value of allLeafValues(d)) {
42+
if (value.toLowerCase().includes(query)) {
43+
return true // Early exit on first match
44+
}
45+
}
46+
return false
47+
})
48+
}
49+
// sort by newest
50+
filtered.sort((a, b) => b.latestDraft.time - a.latestDraft.time)
51+
return filtered
52+
}, [drafts, filters])
53+
54+
const toggleSelection = (id: string) => {
55+
const newSelected = new Set(selectedIds)
56+
if (newSelected.has(id)) {
57+
newSelected.delete(id)
58+
} else {
59+
newSelected.add(id)
60+
}
61+
setSelectedIds(newSelected)
62+
}
63+
64+
const toggleSelectAll = () => {
65+
if (selectedIds.size === filteredDrafts.length && filteredDrafts.length > 0) {
66+
setSelectedIds(new Set())
67+
} else {
68+
setSelectedIds(new Set(filteredDrafts.map((d) => d.spot.unique_key)))
69+
}
70+
}
71+
72+
const handleOpen = (url: string) => {
73+
window.open(url, '_blank')
74+
}
75+
76+
const handleTrash = (row: CommentTableRow) => {
77+
if (row.latestDraft.stats.charCount > 20) {
78+
if (confirm('Are you sure you want to discard this draft?')) {
79+
console.log('Trashing draft:', row.spot.unique_key)
80+
}
81+
} else {
82+
console.log('Trashing draft:', row.spot.unique_key)
83+
}
84+
}
85+
86+
const clearFilters = () => {
87+
setFilters({
88+
searchQuery: '',
89+
sentFilter: 'both',
90+
showTrashed: true,
91+
})
92+
}
93+
94+
const getTableBody = () => {
95+
if (drafts.length === 0) {
96+
return <EmptyState />
97+
}
98+
99+
if (filteredDrafts.length === 0 && (filters.searchQuery || filters.sentFilter !== 'both')) {
100+
return <NoMatchesState onClearFilters={clearFilters} />
101+
}
102+
103+
return filteredDrafts.map((row) =>
104+
commentRow(row, selectedIds, toggleSelection, handleOpen, handleTrash),
105+
)
106+
}
107+
108+
return (
109+
<div className='bg-white'>
110+
{/* Bulk actions bar - floating popup */}
111+
{selectedIds.size > 0 && (
112+
<div className='fixed bottom-6 left-1/2 transform -translate-x-1/2 p-3 bg-blue-50 rounded-md shadow-lg border border-blue-200 flex items-center gap-3 z-50'>
113+
<span className='text-sm font-medium'>{selectedIds.size} selected</span>
114+
<button type='button' className='text-sm text-blue-600 hover:underline'>
115+
Copy
116+
</button>
117+
<button type='button' className='text-sm text-blue-600 hover:underline'>
118+
Preview
119+
</button>
120+
<button type='button' className='text-sm text-blue-600 hover:underline'>
121+
Discard
122+
</button>
123+
<button type='button' className='text-sm text-blue-600 hover:underline'>
124+
Open
125+
</button>
126+
</div>
127+
)}
128+
129+
{/* Table */}
130+
<div className='overflow-x-auto'>
131+
<table className='w-full table-fixed table-fixed'>
132+
<colgroup>
133+
<col className='w-10' />
134+
<col />
135+
</colgroup>
136+
<thead className='border-b border-gray-400'>
137+
<tr>
138+
<th scope='col' className='px-3 py-3'>
139+
<input
140+
type='checkbox'
141+
checked={selectedIds.size === filteredDrafts.length && filteredDrafts.length > 0}
142+
onChange={toggleSelectAll}
143+
aria-label='Select all'
144+
className='rounded'
145+
/>
146+
</th>
147+
<th scope='col' className='px-3 py-3 text-left text-xs text-gray-500'>
148+
<div className='relative'>
149+
<div className='flex items-center gap-1'>
150+
<div className='relative flex-1'>
151+
<Search className='absolute left-1 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400' />
152+
<input
153+
type='text'
154+
placeholder='Search drafts...'
155+
value={filters.searchQuery}
156+
onChange={(e) => updateFilter('searchQuery', e.target.value)}
157+
className='w-full pl-5 pr-3 h-5 border border-gray-300 rounded-sm text-sm font-normal focus:outline-none focus:border-blue-500'
158+
/>
159+
</div>
160+
<div className='relative flex overflow-hidden gap-1'>
161+
<button
162+
type='button'
163+
onClick={() => updateFilter('showTrashed', !filters.showTrashed)}
164+
className={twMerge(
165+
badgeCVA({
166+
clickable: true,
167+
type: filters.showTrashed ? 'trashed' : 'hideTrashed',
168+
}),
169+
'border',
170+
)}
171+
>
172+
<Trash2 className='w-3 h-3' />
173+
{filters.showTrashed ? (
174+
<Eye className='w-3 h-3' />
175+
) : (
176+
<EyeOff className='w-3 h-3' />
177+
)}
178+
</button>
179+
<MultiSegment<FilterState['sentFilter']>
180+
value={filters.sentFilter}
181+
onValueChange={(value) => updateFilter('sentFilter', value)}
182+
segments={[
183+
{
184+
text: '',
185+
type: 'unsent',
186+
value: 'unsent',
187+
},
188+
{
189+
text: 'both',
190+
type: 'blank',
191+
value: 'both',
192+
},
193+
{
194+
text: '',
195+
type: 'sent',
196+
value: 'sent',
197+
},
198+
]}
199+
/>
200+
<button
201+
type='button'
202+
className={twMerge(
203+
badgeCVA({
204+
clickable: true,
205+
type: 'settings',
206+
}),
207+
'border',
208+
)}
209+
>
210+
<Settings className='w-3 h-3' />
211+
</button>
212+
</div>
213+
</div>
214+
</div>
215+
</th>
216+
</tr>
217+
</thead>
218+
<tbody className='divide-y divide-gray-200'>{getTableBody()}</tbody>
219+
</table>
220+
</div>
221+
</div>
222+
)
223+
}
224+
225+
const enhancers = new EnhancerRegistry()
226+
function commentRow(
227+
row: CommentTableRow,
228+
selectedIds: Set<unknown>,
229+
toggleSelection: (id: string) => void,
230+
_handleOpen: (url: string) => void,
231+
_handleTrash: (row: CommentTableRow) => void,
232+
) {
233+
const enhancer = enhancers.enhancerFor(row.spot)
234+
return (
235+
<tr key={row.spot.unique_key} className='hover:bg-gray-50'>
236+
<td className='px-3 py-3'>
237+
<input
238+
type='checkbox'
239+
checked={selectedIds.has(row.spot.unique_key)}
240+
onChange={() => toggleSelection(row.spot.unique_key)}
241+
className='rounded'
242+
/>
243+
</td>
244+
<td className='px-3 py-3'>
245+
<div className='space-y-1'>
246+
{/* Context line */}
247+
<div className='flex items-center justify-between gap-1.5 text-xs text-gray-600'>
248+
<div className='flex items-center gap-1.5 min-w-0 flex-1'>
249+
{enhancer.tableUpperDecoration(row.spot)}
250+
</div>
251+
<div className='flex items-center gap-1 flex-shrink-0'>
252+
{row.latestDraft.stats.links.length > 0 && (
253+
<Badge type='link' text={row.latestDraft.stats.links.length} />
254+
)}
255+
{row.latestDraft.stats.images.length > 0 && (
256+
<Badge type='image' text={row.latestDraft.stats.images.length} />
257+
)}
258+
{row.latestDraft.stats.codeBlocks.length > 0 && (
259+
<Badge type='code' text={row.latestDraft.stats.codeBlocks.length} />
260+
)}
261+
<Badge type='text' text={row.latestDraft.stats.charCount} />
262+
<Badge type='time' text={timeAgo(row.latestDraft.time)} />
263+
{row.isOpenTab && <Badge type='open' />}
264+
</div>
265+
</div>
266+
267+
{/* Title */}
268+
<div className='flex items-center gap-1'>
269+
<a href='TODO' className='text-sm font-medium hover:underline truncate'>
270+
{enhancer.tableTitle(row.spot)}
271+
</a>
272+
<Badge type={row.isSent ? 'sent' : 'unsent'} />
273+
{row.isTrashed && <Badge type='trashed' />}
274+
</div>
275+
{/* Draft */}
276+
<div className='text-sm truncate'>
277+
<span className='text-gray-500'>{row.latestDraft.content.substring(0, 100)}</span>
278+
</div>
279+
</div>
280+
</td>
281+
</tr>
282+
)
283+
}
284+
285+
const EmptyState = () => (
286+
<div className='max-w-4xl mx-auto text-center py-16'>
287+
<h2 className='text-2xl font-semibold mb-4'>No comments open</h2>
288+
<p className='text-gray-600 mb-6'>
289+
Your drafts will appear here when you start typing in comment boxes across GitHub and Reddit.
290+
</p>
291+
<div className='space-y-2'>
292+
<button type='button' className='text-blue-600 hover:underline'>
293+
How it works
294+
</button>
295+
<span className='mx-2'>·</span>
296+
<button type='button' className='text-blue-600 hover:underline'>
297+
Check permissions
298+
</button>
299+
</div>
300+
</div>
301+
)
302+
303+
const NoMatchesState = ({ onClearFilters }: { onClearFilters: () => void }) => (
304+
<div className='text-center py-16'>
305+
<p className='text-gray-600 mb-4'>No matches found</p>
306+
<button type='button' onClick={onClearFilters} className='text-blue-600 hover:underline'>
307+
Clear filters
308+
</button>
309+
</div>
310+
)

browser-extension/src/components/SpotRow.tsx

Lines changed: 0 additions & 42 deletions
This file was deleted.

0 commit comments

Comments
 (0)