Initial scaffold: Researcher Endorsement frontend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-14 20:18:24 -05:00
commit 333c31c912
79 changed files with 4806 additions and 0 deletions

35
src/App.tsx Normal file
View File

@@ -0,0 +1,35 @@
import React, { useEffect } from 'react'
import { Routes, Route } from 'react-router-dom'
import Home from './routes/Home'
import Profile from './routes/Profile'
import CreateUser from './routes/CreateUser'
import PostDetail from './routes/PostDetail'
import Navbar from './components/Navbar'
import CreatePostModal from './components/CreatePostModal'
import useAppStore from './store/useAppStore'
const App: React.FC = () => {
const seedData = useAppStore((s) => s.seedData)
useEffect(() => {
seedData()
}, [seedData])
return (
<div className="app">
<div className="nav">
<Navbar />
</div>
<CreatePostModal />
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="/create-user" element={<CreateUser />} />
<Route path="/post/:id" element={<PostDetail />} />
</Routes>
</main>
</div>
)
}
export default App

View File

@@ -0,0 +1,63 @@
import React, { useState, startTransition } from 'react'
import useAppStore from '../store/useAppStore'
const CreatePostModal: React.FC = () => {
const isOpen = useAppStore((s) => s.ui.isCreatePostOpen)
const toggle = useAppStore((s) => s.toggleCreatePost)
const currentUserId = useAppStore((s) => s.currentUserId)
const createPost = useAppStore((s) => s.createPost)
const [content, setContent] = useState('')
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState<string | null>(null)
if (!isOpen) return null
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!currentUserId) {
setError('Select a user first')
return
}
if (!content.trim()) {
setError('Content required')
return
}
setError(null)
startTransition(() => {
const attachedPDF = file ? { name: file.name, url: URL.createObjectURL(file) } : undefined
createPost({ authorId: currentUserId, content: content.trim(), attachedPDF })
toggle()
setContent('')
setFile(null)
})
}
return (
<div className="modal-backdrop">
<div className="modal">
<h3>Create Post</h3>
<form onSubmit={onSubmit}>
<textarea className="textarea" value={content} onChange={(e) => setContent(e.target.value)} />
<div style={{marginTop:8}}>
<input type="file" accept="application/pdf" onChange={(e) => {
const f = e.target.files && e.target.files[0]
if (f && f.type !== 'application/pdf') {
setError('Only PDF files allowed')
return
}
setFile(f ?? null)
}} />
</div>
{error && <div style={{color:'red'}}>{error}</div>}
<div style={{marginTop:8,display:'flex',justifyContent:'flex-end',gap:8}}>
<button type="button" onClick={() => toggle()}>Cancel</button>
<button className="button" type="submit" disabled={!currentUserId}>Post</button>
</div>
</form>
</div>
</div>
)
}
export default CreatePostModal

View File

@@ -0,0 +1,11 @@
import React from 'react'
const EndorseButton: React.FC<{ onClick: () => void; count: number; disabled?: boolean }> = ({ onClick, count, disabled }) => {
return (
<button className="button" onClick={onClick} disabled={disabled}>
Endorse ({count})
</button>
)
}
export default EndorseButton

18
src/components/Feed.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react'
import useAppStore from '../store/useAppStore'
import PostCard from './PostCard'
const Feed: React.FC = () => {
const posts = useAppStore((s) => s.posts)
const sorted = [...posts].sort((a,b) => b.createdAt - a.createdAt)
if (sorted.length === 0) return <div className="card">No posts yet.</div>
return (
<div>
{sorted.map((p) => (
<PostCard key={p.id} post={p} />
))}
</div>
)
}
export default Feed

30
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react'
import { Link } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
const Navbar: React.FC = () => {
const users = useAppStore((s) => s.users)
const currentUserId = useAppStore((s) => s.currentUserId)
const setCurrentUser = useAppStore((s) => s.setCurrentUser)
const toggleCreatePost = useAppStore((s) => s.toggleCreatePost)
return (
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<div>
<Link to="/">Researcher Endorsement</Link>
<Link to="/create-user" style={{marginLeft:12}}>Create User</Link>
</div>
<div style={{display:'flex',alignItems:'center',gap:12}}>
<select value={currentUserId ?? ''} onChange={(e) => setCurrentUser(e.target.value || null)}>
<option value="">(No user)</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
<button className="button" onClick={() => toggleCreatePost()} disabled={!currentUserId}>Create Post</button>
</div>
</div>
)
}
export default Navbar

View File

@@ -0,0 +1,23 @@
import React, { useEffect } from 'react'
const PDFPreview: React.FC<{ url: string; name?: string }> = ({ url, name }) => {
useEffect(() => {
return () => {
// only revoke blob URLs
try {
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url)
} catch (e) {
// ignore
}
}
}, [url])
return (
<div>
<div className="small">{name}</div>
<iframe src={url} width="100%" height={300} title={name ?? 'pdf'} />
</div>
)
}
export default PDFPreview

View File

@@ -0,0 +1,43 @@
import React, { memo } from 'react'
import { Post } from '../types/Post'
import useAppStore from '../store/useAppStore'
import { Link } from 'react-router-dom'
import { formatTime } from '../utils/fileHelpers'
import PDFPreview from './PDFPreview'
const PostCard: React.FC<{ post: Post }> = ({ post }) => {
const author = useAppStore((s) => s.users.find((u) => u.id === post.authorId))
const endorsePost = useAppStore((s) => s.endorsePost)
if (!author) return null
return (
<div className="card">
<div style={{display:'flex',justifyContent:'space-between'}}>
<div>
<Link to={`/profile/${author.id}`}><strong>{author.name}</strong></Link>
<div className="small">{formatTime(post.createdAt)} ago</div>
</div>
<div style={{display:'flex',gap:6}}>
{author.specialties.map((sp) => (
<span key={sp} className="tag">{sp}</span>
))}
</div>
</div>
<p style={{marginTop:8}}>{post.content}</p>
{post.attachedPDF && (
<div style={{marginTop:8}}>
<PDFPreview url={post.attachedPDF.url} name={post.attachedPDF.name} />
</div>
)}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginTop:8}}>
<div>
<button className="button" onClick={() => endorsePost(post.id)}>Endorse Post ({post.endorsements})</button>
<Link to={`/post/${post.id}`} style={{marginLeft:8}}>View</Link>
</div>
</div>
</div>
)
}
export default memo(PostCard)

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { User } from '../types/User'
import { Link } from 'react-router-dom'
const UserCard: React.FC<{ user: User }> = ({ user }) => {
return (
<div className="card">
<Link to={`/profile/${user.id}`}><strong>{user.name}</strong></Link>
<div className="small">{user.bio}</div>
<div style={{marginTop:8}}>
{user.specialties.map((s) => (
<span key={s} className="tag">{s}</span>
))}
</div>
</div>
)
}
export default UserCard

View File

@@ -0,0 +1,12 @@
import useAppStore from '../store/useAppStore'
import { User } from '../types/User'
export const useCurrentUser = (): { currentUser: User | null; setCurrentUser: (id: string | null) => void } => {
const currentUserId = useAppStore((s) => s.currentUserId)
const users = useAppStore((s) => s.users)
const setCurrentUser = useAppStore((s) => s.setCurrentUser)
const currentUser = users.find((u) => u.id === currentUserId) ?? null
return { currentUser, setCurrentUser }
}
export default useCurrentUser

18
src/index.css Normal file
View File

@@ -0,0 +1,18 @@
/* Minimal styling for layout and cards */
:root{--max-width:900px;--card-bg:#fff;--muted:#6b7280}
*{box-sizing:border-box}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial;line-height:1.4;margin:0;background:#f3f4f6;color:#111}
.app main{padding:1rem}
.container{max-width:var(--max-width);margin:0 auto}
.card{background:var(--card-bg);border:1px solid #e5e7eb;border-radius:8px;padding:12px;margin-bottom:12px}
.flex{display:flex}
.center{display:flex;align-items:center;justify-content:center}
.tag{background:#eef2ff;color:#1e40af;padding:4px 8px;border-radius:999px;font-size:12px;margin-right:6px}
.button{background:#2563eb;color:#fff;border:none;padding:8px 12px;border-radius:6px;cursor:pointer}
.small{font-size:13px;color:var(--muted)}
.sidebar{width:300px}
.nav{background:#fff;border-bottom:1px solid #e5e7eb;padding:10px}
.nav a{margin-right:12px;color:#111;text-decoration:none}
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center}
.modal{background:#fff;border-radius:8px;padding:16px;width:min(600px,95%)}
.textarea{width:100%;min-height:100px;padding:8px;border:1px solid #e5e7eb;border-radius:6px}

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)

72
src/mock/seedData.ts Normal file
View File

@@ -0,0 +1,72 @@
import { User } from '../types/User'
import { Post } from '../types/Post'
const now = Date.now()
export const seedUsers = (): User[] => {
const users: User[] = [
{
id: 'u-ai',
name: 'Ava Li',
email: 'ava@example.com',
bio: 'Researcher in applied AI and ML systems.',
specialties: ['AI', 'Machine Learning', 'NLP'],
endorsements: { AI: 3, 'Machine Learning': 2 },
createdAt: now - 1000 * 60 * 60 * 24 * 10,
},
{
id: 'u-neuro',
name: 'Daniel Kim',
email: 'daniel@example.com',
bio: 'Neuroscience and cognitive modeling.',
specialties: ['Neuroscience', 'Cognitive Science'],
endorsements: { Neuroscience: 4 },
createdAt: now - 1000 * 60 * 60 * 24 * 9,
},
{
id: 'u-climate',
name: 'Maria Gomez',
email: 'maria@example.com',
bio: 'Climate science and environmental data.',
specialties: ['Climate Science', 'Data Analysis'],
endorsements: { 'Climate Science': 1 },
createdAt: now - 1000 * 60 * 60 * 24 * 8,
},
{
id: 'u-quantum',
name: 'Liam O\'Connor',
email: 'liam@example.com',
bio: 'Quantum information and condensed matter.',
specialties: ['Quantum Physics', 'Condensed Matter'],
endorsements: { 'Quantum Physics': 2 },
createdAt: now - 1000 * 60 * 60 * 24 * 7,
},
{
id: 'u-econ',
name: 'Sofia Patel',
email: 'sofia@example.com',
bio: 'Behavioral economics and market design.',
specialties: ['Economics', 'Behavioral Economics'],
endorsements: { Economics: 5 },
createdAt: now - 1000 * 60 * 60 * 24 * 6,
},
]
return users
}
export const seedPosts = (users: User[]): Post[] => {
const samplePDF = 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'
const posts: Post[] = [
{ id: 'p1', authorId: users[0].id, content: 'Working on a new transformer variant.', attachedPDF: { name: 'paper.pdf', url: samplePDF }, endorsements: 2, createdAt: now - 1000 * 60 * 30 },
{ id: 'p2', authorId: users[1].id, content: 'New results on memory consolidation in models.', endorsements: 1, createdAt: now - 1000 * 60 * 60 * 2 },
{ id: 'p3', authorId: users[2].id, content: 'Dataset release for coastal temperature anomalies.', endorsements: 0, createdAt: now - 1000 * 60 * 60 * 5 },
{ id: 'p4', authorId: users[3].id, content: 'Simulations of topological phases.', endorsements: 3, createdAt: now - 1000 * 60 * 60 * 24 },
{ id: 'p5', authorId: users[4].id, content: 'Market design experiment planned next month.', endorsements: 4, createdAt: now - 1000 * 60 * 60 * 24 * 2 },
{ id: 'p6', authorId: users[0].id, content: 'Trying a new optimization schedule.', endorsements: 1, createdAt: now - 1000 * 60 * 60 * 3 },
{ id: 'p7', authorId: users[1].id, content: 'Open-source code for preprocessing.', endorsements: 2, createdAt: now - 1000 * 60 * 60 * 6 },
{ id: 'p8', authorId: users[2].id, content: 'Collaboration call on climate econometrics.', endorsements: 0, createdAt: now - 1000 * 60 * 60 * 12 },
{ id: 'p9', authorId: users[3].id, content: 'Preprint draft available.', attachedPDF: { name: 'draft.pdf', url: samplePDF }, endorsements: 1, createdAt: now - 1000 * 60 * 60 * 48 },
{ id: 'p10', authorId: users[4].id, content: 'Survey on lab replication practices.', endorsements: 0, createdAt: now - 1000 * 60 * 60 * 72 },
]
return posts
}

51
src/routes/CreateUser.tsx Normal file
View File

@@ -0,0 +1,51 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
const CreateUser: React.FC = () => {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [bio, setBio] = useState('')
const [specialties, setSpecialties] = useState('')
const createUser = useAppStore((s) => s.createUser)
const navigate = useNavigate()
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
const parsed = specialties.split(',').map((s) => s.trim()).filter(Boolean)
if (!name || !email || !bio || parsed.length === 0) return alert('All fields required and at least one specialty')
const newUser = createUser({ name, email, bio, specialties: parsed })
navigate(`/profile/${newUser.id}`)
}
return (
<div className="container">
<div className="card">
<h2>Create User</h2>
<form onSubmit={onSubmit}>
<div>
<label>Name</label>
<input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label>Email</label>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label>Bio</label>
<input value={bio} onChange={(e) => setBio(e.target.value)} />
</div>
<div>
<label>Specialties (comma separated)</label>
<input value={specialties} onChange={(e) => setSpecialties(e.target.value)} />
</div>
<div style={{marginTop:8}}>
<button className="button" type="submit">Create</button>
</div>
</form>
</div>
</div>
)
}
export default CreateUser

26
src/routes/Home.tsx Normal file
View File

@@ -0,0 +1,26 @@
import React from 'react'
import Feed from '../components/Feed'
import UserCard from '../components/UserCard'
import useAppStore from '../store/useAppStore'
const Home: React.FC = () => {
const users = useAppStore((s) => s.users)
const currentUserId = useAppStore((s) => s.currentUserId)
const suggestions = users.filter((u) => u.id !== currentUserId).slice(0, 3)
return (
<div className="container" style={{display:'flex',gap:16}}>
<div style={{flex:1}}>
<Feed />
</div>
<aside className="sidebar">
<h3>Suggested Researchers</h3>
{suggestions.map((u) => (
<UserCard key={u.id} user={u} />
))}
</aside>
</div>
)
}
export default Home

29
src/routes/PostDetail.tsx Normal file
View File

@@ -0,0 +1,29 @@
import React from 'react'
import { useParams } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
import PDFPreview from '../components/PDFPreview'
const PostDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const post = useAppStore((s) => s.posts.find((p) => p.id === id))
const author = useAppStore((s) => s.users.find((u) => u.id === post?.authorId))
const endorsePost = useAppStore((s) => s.endorsePost)
if (!post) return <div className="card">Post not found</div>
return (
<div className="container">
<div className="card">
<h3>{author?.name}</h3>
<div className="small">{author?.bio}</div>
<div style={{marginTop:8}}>{post.content}</div>
{post.attachedPDF && <PDFPreview url={post.attachedPDF.url} name={post.attachedPDF.name} />}
<div style={{marginTop:8}}>
<button className="button" onClick={() => endorsePost(post.id)}>Endorse Post ({post.endorsements})</button>
</div>
</div>
</div>
)
}
export default PostDetail

46
src/routes/Profile.tsx Normal file
View File

@@ -0,0 +1,46 @@
import React from 'react'
import { useParams } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
import EndorseButton from '../components/EndorseButton'
import UserCard from '../components/UserCard'
const Profile: React.FC = () => {
const { id } = useParams<{ id: string }>()
const user = useAppStore((s) => s.users.find((u) => u.id === id))
const posts = useAppStore((s) => s.posts.filter((p) => p.authorId === id))
const endorseUser = useAppStore((s) => s.endorseUser)
const currentUserId = useAppStore((s) => s.currentUserId)
if (!user) return <div className="card">User not found</div>
return (
<div className="container">
<div className="card">
<h2>{user.name}</h2>
<div className="small">{user.bio}</div>
<div style={{marginTop:8}}>
{user.specialties.map((s) => (
<div key={s} style={{display:'flex',alignItems:'center',gap:8,marginTop:6}}>
<span className="tag">{s}</span>
<div className="small">Endorsements: {user.endorsements[s] ?? 0}</div>
<div>
<EndorseButton onClick={() => endorseUser(user.id, s)} count={user.endorsements[s] ?? 0} disabled={currentUserId === user.id} />
</div>
</div>
))}
</div>
</div>
<h3>Posts</h3>
{posts.length === 0 && <div className="card">No posts yet.</div>}
{posts.map((p) => (
<div key={p.id} className="card">
<UserCard user={user} />
<div style={{marginTop:8}}>{p.content}</div>
</div>
))}
</div>
)
}
export default Profile

97
src/store/useAppStore.ts Normal file
View File

@@ -0,0 +1,97 @@
import create from 'zustand'
import { User } from '../types/User'
import { Post } from '../types/Post'
import { seedUsers, seedPosts } from '../mock/seedData'
type UIState = { isCreatePostOpen: boolean }
type AppState = {
users: User[]
posts: Post[]
currentUserId: string | null
selectedPostId: string | null
ui: UIState
seedData: () => void
createUser: (data: { name: string; email: string; bio: string; specialties: string[] }) => User
setCurrentUser: (id: string | null) => void
createPost: (data: { authorId: string; content: string; attachedPDF?: { name: string; url: string } }) => Post
endorseUser: (userId: string, specialty: string) => void
endorsePost: (postId: string) => void
attachPDFToPost: (postId: string, file: File) => void
toggleCreatePost: () => void
}
const makeId = () => (typeof crypto !== 'undefined' && 'randomUUID' in crypto ? (crypto as any).randomUUID() : 'id-' + Date.now())
const useAppStore = create<AppState>((set, get) => ({
users: [],
posts: [],
currentUserId: null,
selectedPostId: null,
ui: { isCreatePostOpen: false },
seedData: () => {
const users = seedUsers()
const posts = seedPosts(users)
set(() => ({ users, posts }))
},
createUser: (data) => {
const id = makeId()
const newUser: User = {
id,
name: data.name,
email: data.email,
bio: data.bio,
specialties: data.specialties,
endorsements: {},
createdAt: Date.now(),
}
set((state) => ({ users: [newUser, ...state.users], currentUserId: id }))
return newUser
},
setCurrentUser: (id) => set(() => ({ currentUserId: id })),
createPost: (data) => {
const id = makeId()
const newPost: Post = {
id,
authorId: data.authorId,
content: data.content,
attachedPDF: data.attachedPDF,
endorsements: 0,
createdAt: Date.now(),
}
set((state) => ({ posts: [newPost, ...state.posts] }))
return newPost
},
endorseUser: (userId, specialty) => {
set((state) => ({
users: state.users.map((u) => {
if (u.id !== userId) return u
const current = { ...u.endorsements }
current[specialty] = (current[specialty] || 0) + 1
return { ...u, endorsements: current }
}),
}))
},
endorsePost: (postId) => {
set((state) => ({
posts: state.posts.map((p) => (p.id === postId ? { ...p, endorsements: p.endorsements + 1 } : p)),
}))
},
attachPDFToPost: (postId, file) => {
const url = URL.createObjectURL(file)
set((state) => ({
posts: state.posts.map((p) => (p.id === postId ? { ...p, attachedPDF: { name: file.name, url } } : p)),
}))
},
toggleCreatePost: () => set((state) => ({ ui: { isCreatePostOpen: !state.ui.isCreatePostOpen } })),
}))
export default useAppStore

13
src/types/Post.ts Normal file
View File

@@ -0,0 +1,13 @@
export interface AttachedPDF {
name: string
url: string
}
export interface Post {
id: string
authorId: string
content: string
attachedPDF?: AttachedPDF
endorsements: number
createdAt: number
}

9
src/types/User.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface User {
id: string
name: string
email: string
bio: string
specialties: string[]
endorsements: Record<string, number>
createdAt: number
}

11
src/utils/fileHelpers.ts Normal file
View File

@@ -0,0 +1,11 @@
export const formatTime = (ts: number) => {
const diff = Date.now() - ts
const sec = Math.floor(diff / 1000)
if (sec < 60) return `${sec}s`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h`
const day = Math.floor(hr / 24)
return `${day}d`
}