Skip to content

Commit e6a4a4c

Browse files
committed
feat: Board UX overhaul - post form below grid, Solscan tx links, active filter
- Move Post Job form below job grid as a styled card section - Add Solscan devnet links to all 7 tx log messages (clickable) - Add Active/All filter toggle for job list - Add Job Board nav link to pipeline page header - Remove ZK Compression from README (not implemented) - Fix appearance CSS lint
1 parent c1fc083 commit e6a4a4c

4 files changed

Lines changed: 166 additions & 69 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,6 @@ This demonstrates the real use case — one wallet posts a job, another bids on
134134

135135
- **On-Chain**: Anchor (Rust) on Solana devnet
136136
- **Ephemeral Rollups**: MagicBlock SDK for delegation + gasless bidding
137-
- **ZK Compression**: Light Protocol for cost-efficient on-chain state (roadmap)
138137
- **Client**: React + TypeScript + Vite
139138
- **Wallet**: Phantom / Solflare via `@solana/wallet-adapter`
140139
- **Metadata**: Cloudflare Workers + R2 (content-addressed storage)

client/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,7 @@ function App() {
773773
<span className="logo-text">TaskForest</span>
774774
</a>
775775
<div className="header-right">
776+
<a href="/board" className="pipeline-nav-link">📋 Job Board</a>
776777
<div className="network-badge">
777778
<span className="dot" /> devnet
778779
</div>

client/src/Board.css

Lines changed: 96 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -140,17 +140,100 @@
140140
margin-left: auto;
141141
}
142142

143-
.post-job-group {
143+
.board-filter {
144144
display: flex;
145-
align-items: center;
145+
gap: 2px;
146+
background: rgba(255, 255, 255, 0.04);
147+
border-radius: 8px;
148+
padding: 2px;
149+
}
150+
151+
.filter-btn {
152+
padding: 0.3rem 0.7rem;
153+
border: none;
154+
border-radius: 6px;
155+
background: transparent;
156+
color: var(--text-dim);
157+
font-size: 0.75rem;
158+
font-weight: 600;
159+
cursor: pointer;
160+
transition: all 0.2s;
161+
}
162+
163+
.filter-btn.active {
164+
background: rgba(52, 211, 153, 0.15);
165+
color: var(--accent);
166+
}
167+
168+
.filter-btn:hover:not(.active) {
169+
color: var(--text);
170+
}
171+
172+
.tx-link {
173+
color: var(--accent);
174+
text-decoration: none;
175+
font-family: 'JetBrains Mono', monospace;
176+
font-size: 0.8rem;
177+
}
178+
179+
.tx-link:hover {
180+
text-decoration: underline;
181+
}
182+
183+
/* Post Card */
184+
.board-post-card {
185+
background: var(--bg-glass);
186+
backdrop-filter: blur(20px);
187+
border: 1px solid var(--border);
188+
border-radius: 16px;
189+
padding: 1.2rem 1.5rem;
190+
margin-bottom: 1rem;
191+
}
192+
193+
.board-post-card h3 {
194+
margin: 0 0 0.8rem;
195+
font-size: 0.95rem;
196+
color: var(--text);
197+
}
198+
199+
.post-form {
200+
display: flex;
201+
flex-direction: column;
146202
gap: 0.5rem;
147203
}
148204

205+
.post-input {
206+
padding: 0.55rem 0.8rem;
207+
background: rgba(255, 255, 255, 0.04);
208+
border: 1px solid var(--border);
209+
border-radius: 8px;
210+
color: var(--text);
211+
font-size: 0.85rem;
212+
font-family: 'Inter', sans-serif;
213+
outline: none;
214+
transition: border-color 0.2s;
215+
}
216+
217+
.post-input:focus {
218+
border-color: var(--accent);
219+
}
220+
221+
.post-input::placeholder {
222+
color: var(--text-dim);
223+
}
224+
225+
.post-row {
226+
display: flex;
227+
align-items: center;
228+
gap: 0.6rem;
229+
margin-top: 0.3rem;
230+
}
231+
149232
.reward-input-wrap {
150233
display: flex;
151234
align-items: center;
152235
gap: 0.3rem;
153-
background: var(--bg-glass);
236+
background: rgba(255, 255, 255, 0.04);
154237
border: 1px solid var(--border);
155238
border-radius: 8px;
156239
padding: 0.4rem 0.6rem;
@@ -165,6 +248,7 @@
165248
font-size: 0.85rem;
166249
font-weight: 600;
167250
outline: none;
251+
appearance: textfield;
168252
-moz-appearance: textfield;
169253
}
170254

@@ -180,26 +264,17 @@
180264
font-weight: 600;
181265
}
182266

183-
.desc-input {
184-
flex: 1;
185-
min-width: 180px;
186-
padding: 0.55rem 0.8rem;
187-
background: var(--bg-glass);
188-
border: 1px solid var(--border);
189-
border-radius: 8px;
190-
color: var(--text);
191-
font-size: 0.85rem;
192-
font-family: 'Inter', sans-serif;
193-
outline: none;
194-
transition: border-color 0.2s;
195-
}
196-
197-
.desc-input:focus {
198-
border-color: var(--accent);
267+
/* Pipeline nav link */
268+
.pipeline-nav-link {
269+
color: var(--text-dim);
270+
text-decoration: none;
271+
font-size: 0.82rem;
272+
font-weight: 600;
273+
transition: color 0.2s;
199274
}
200275

201-
.desc-input::placeholder {
202-
color: var(--text-dim);
276+
.pipeline-nav-link:hover {
277+
color: var(--accent);
203278
}
204279

205280
.job-desc {

client/src/Board.tsx

Lines changed: 69 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback, useMemo } from 'react'
1+
import React, { useState, useEffect, useCallback, useMemo } from 'react'
22
import { useConnection, useWallet } from '@solana/wallet-adapter-react'
33
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui'
44
import {
@@ -55,12 +55,13 @@ export default function Board() {
5555
const { publicKey, connected, sendTransaction } = useWallet()
5656
const [jobs, setJobs] = useState<JobOnChain[]>([])
5757
const [loading, setLoading] = useState(false)
58-
const [actionLog, setActionLog] = useState<string[]>([])
58+
const [actionLog, setActionLog] = useState<React.ReactNode[]>([])
5959
const [acting, setActing] = useState<string | null>(null)
6060
const [rewardSol, setRewardSol] = useState('0.1')
6161
const [jobDesc, setJobDesc] = useState('Summarize this research paper')
6262
const [jobTitle, setJobTitle] = useState('Research Task')
6363
const [metadataMap, setMetadataMap] = useState<Record<string, TaskMetadata>>({})
64+
const [activeOnly, setActiveOnly] = useState(false)
6465

6566
const erBurner = useMemo(() => getBurner(), [])
6667

@@ -74,10 +75,16 @@ export default function Board() {
7475
return new anchor.Program(idl as any, provider)
7576
}, [publicKey, connection])
7677

77-
const log = useCallback((msg: string) => {
78-
setActionLog(prev => [...prev.slice(-20), `${new Date().toLocaleTimeString()} ${msg}`])
78+
const log = useCallback((msg: React.ReactNode) => {
79+
setActionLog(prev => [...prev.slice(-20), <span key={Date.now()}>[{new Date().toLocaleTimeString()}] {msg}</span>])
7980
}, [])
8081

82+
const txLink = (sig: string) => (
83+
<a href={`https://solscan.io/tx/${sig}?cluster=devnet`} target="_blank" rel="noreferrer" className="tx-link">
84+
{sig.slice(0, 12)}
85+
</a>
86+
)
87+
8188
// Fetch all jobs
8289
const fetchJobs = useCallback(async () => {
8390
setLoading(true)
@@ -210,7 +217,7 @@ export default function Board() {
210217
.transaction()
211218

212219
const sig = await sendTx(connection, tx)
213-
log(`✅ Job #${jobId} created (${rewardSol} SOL escrowed) tx:${sig.slice(0, 12)}...`)
220+
log(<>✅ Job #{jobId} created ({rewardSol} SOL escrowed) tx:{txLink(sig)}</>)
214221

215222
// Save hash + metadata locally for display
216223
localStorage.setItem(`tf_hash_${jobPDA.toBase58()}`, metaHash)
@@ -224,7 +231,7 @@ export default function Board() {
224231
.accounts({ payer: publicKey, job: jobPDA })
225232
.transaction()
226233
const sig2 = await sendTx(connection, delegateTx)
227-
log(`✅ Job is now open for bidding! tx:${sig2.slice(0, 12)}...`)
234+
log(<>✅ Job is now open for bidding! tx:{txLink(sig2)}</>)
228235

229236
await fetchJobs()
230237
} catch (e) {
@@ -262,7 +269,7 @@ export default function Board() {
262269
tx.feePayer = erBurner.publicKey
263270
tx.sign(erBurner)
264271
const sig = await erConn.sendRawTransaction(tx.serialize())
265-
log(`⚡ Bid placed! stake=${(stakeAmount / LAMPORTS_PER_SOL).toFixed(4)} SOL tx:${sig.slice(0, 12)}...`)
272+
log(<>⚡ Bid placed! stake={(stakeAmount / LAMPORTS_PER_SOL).toFixed(4)} SOL tx:{txLink(sig)}</>)
266273

267274
// Auto-close bidding
268275
log('Closing bidding...')
@@ -275,7 +282,7 @@ export default function Board() {
275282
closeTx.feePayer = erBurner.publicKey
276283
closeTx.sign(erBurner)
277284
const sig2 = await erConn.sendRawTransaction(closeTx.serialize())
278-
log(`🔒 Bidding closed, committing to L1... tx:${sig2.slice(0, 12)}...`)
285+
log(<>🔒 Bidding closed, committing to L1... tx:{txLink(sig2)}</>)
279286

280287
// Poll for L1 commit
281288
for (let i = 0; i < 24; i++) {
@@ -313,7 +320,7 @@ export default function Board() {
313320
.accounts({ job: job.pubkey, claimer: erBurner.publicKey, systemProgram: SystemProgram.programId })
314321
.transaction()
315322
const sig = await sendL1WithBurner(tx)
316-
log(`💎 Stake locked! tx:${sig.slice(0, 12)}...`)
323+
log(<>💎 Stake locked! tx:{txLink(sig)}</>)
317324
await fetchJobs()
318325
} catch (e) {
319326
log(`❌ Lock stake failed: ${(e as Error).message.slice(0, 150)}`)
@@ -331,7 +338,7 @@ export default function Board() {
331338
.accounts({ job: job.pubkey, submitter: erBurner.publicKey })
332339
.transaction()
333340
const sig = await sendL1WithBurner(tx)
334-
log(`📝 Proof submitted! tx:${sig.slice(0, 12)}...`)
341+
log(<>📝 Proof submitted! tx:{txLink(sig)}</>)
335342
await fetchJobs()
336343
} catch (e) {
337344
log(`❌ Proof failed: ${(e as Error).message.slice(0, 150)}`)
@@ -355,7 +362,7 @@ export default function Board() {
355362
})
356363
.transaction()
357364
const sig = await sendTx(connection, tx)
358-
log(`${verdict === 1 ? '✅' : '❌'} Job ${action}! SOL transferred. tx:${sig.slice(0, 12)}...`)
365+
log(<>{verdict === 1 ? '✅' : '❌'} Job {action}! SOL transferred. tx:{txLink(sig)}</>)
359366
await fetchJobs()
360367
} catch (e) {
361368
log(`❌ Settle failed: ${(e as Error).message.slice(0, 150)}`)
@@ -388,53 +395,26 @@ export default function Board() {
388395
</header>
389396

390397
<div className="board-actions-bar">
391-
<div className="post-job-group">
392-
<button className="board-btn board-btn-post" onClick={postJob} disabled={!connected || !!acting}>
393-
{acting === 'new' ? '⏳ Posting...' : `➕ Post Job`}
394-
</button>
395-
<div className="reward-input-wrap">
396-
<input
397-
type="number"
398-
className="reward-input"
399-
value={rewardSol}
400-
onChange={e => setRewardSol(e.target.value)}
401-
min="0.01"
402-
step="0.05"
403-
disabled={!!acting}
404-
/>
405-
<span className="reward-suffix">SOL</span>
406-
</div>
407-
</div>
408-
<input
409-
type="text"
410-
className="desc-input desc-title"
411-
placeholder="Task title"
412-
value={jobTitle}
413-
onChange={e => setJobTitle(e.target.value)}
414-
disabled={!!acting}
415-
/>
416-
<input
417-
type="text"
418-
className="desc-input"
419-
placeholder="Describe the task..."
420-
value={jobDesc}
421-
onChange={e => setJobDesc(e.target.value)}
422-
disabled={!!acting}
423-
/>
424398
<button className="board-btn board-btn-refresh" onClick={fetchJobs} disabled={loading}>
425399
{loading ? '⏳ Loading...' : '🔄 Refresh'}
426400
</button>
427-
<span className="board-job-count">{jobs.length} jobs on-chain</span>
401+
<div className="board-filter">
402+
<button className={`filter-btn ${!activeOnly ? 'active' : ''}`} onClick={() => setActiveOnly(false)}>All</button>
403+
<button className={`filter-btn ${activeOnly ? 'active' : ''}`} onClick={() => setActiveOnly(true)}>Active</button>
404+
</div>
405+
<span className="board-job-count">
406+
{activeOnly ? jobs.filter(j => j.status < 4).length : jobs.length} jobs{activeOnly ? ' active' : ' on-chain'}
407+
</span>
428408
</div>
429409

430410
{/* Job Grid */}
431411
<section className="board-grid">
432412
{jobs.length === 0 && !loading && (
433413
<div className="board-empty">
434-
{connected ? 'No jobs found. Post the first one!' : 'Connect wallet to browse jobs'}
414+
{connected ? 'No jobs found. Post the first one below!' : 'Connect wallet to browse jobs'}
435415
</div>
436416
)}
437-
{jobs.map(job => {
417+
{jobs.filter(j => !activeOnly || j.status < 4).map(job => {
438418
const statusInfo = STATUS_LABELS[job.status] || { label: `Status ${job.status}`, color: '#888', icon: '❓' }
439419
const isMyJob = isPoster(job)
440420
const isActing = acting === job.pubkey.toBase58()
@@ -546,6 +526,48 @@ export default function Board() {
546526
})}
547527
</section>
548528

529+
{/* Post New Job */}
530+
{connected && (
531+
<section className="board-post-card glass">
532+
<h3>➕ Post a New Task</h3>
533+
<div className="post-form">
534+
<input
535+
type="text"
536+
className="post-input"
537+
placeholder="Task title"
538+
value={jobTitle}
539+
onChange={e => setJobTitle(e.target.value)}
540+
disabled={!!acting}
541+
/>
542+
<input
543+
type="text"
544+
className="post-input"
545+
placeholder="Describe what needs to be done..."
546+
value={jobDesc}
547+
onChange={e => setJobDesc(e.target.value)}
548+
disabled={!!acting}
549+
/>
550+
<div className="post-row">
551+
<div className="reward-input-wrap">
552+
<input
553+
type="number"
554+
className="reward-input"
555+
value={rewardSol}
556+
onChange={e => setRewardSol(e.target.value)}
557+
min="0.01"
558+
step="0.05"
559+
disabled={!!acting}
560+
/>
561+
<span className="reward-suffix">SOL</span>
562+
</div>
563+
<button className="board-btn board-btn-post" onClick={postJob} disabled={!connected || !!acting}>
564+
{acting === 'new' ? '⏳ Posting...' : '🚀 Post Task'}
565+
</button>
566+
</div>
567+
</div>
568+
</section>
569+
)}
570+
549571
{/* Action Log */}
550572
{actionLog.length > 0 && (
551573
<section className="board-log glass">

0 commit comments

Comments
 (0)