Initial scaffold: Researcher Endorsement frontend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
35
src/App.tsx
Normal file
35
src/App.tsx
Normal 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
|
||||
63
src/components/CreatePostModal.tsx
Normal file
63
src/components/CreatePostModal.tsx
Normal 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
|
||||
11
src/components/EndorseButton.tsx
Normal file
11
src/components/EndorseButton.tsx
Normal 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
18
src/components/Feed.tsx
Normal 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
30
src/components/Navbar.tsx
Normal 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
|
||||
23
src/components/PDFPreview.tsx
Normal file
23
src/components/PDFPreview.tsx
Normal 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
|
||||
43
src/components/PostCard.tsx
Normal file
43
src/components/PostCard.tsx
Normal 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)
|
||||
19
src/components/UserCard.tsx
Normal file
19
src/components/UserCard.tsx
Normal 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
|
||||
12
src/hooks/useCurrentUser.ts
Normal file
12
src/hooks/useCurrentUser.ts
Normal 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
18
src/index.css
Normal 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
13
src/main.tsx
Normal 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
72
src/mock/seedData.ts
Normal 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
51
src/routes/CreateUser.tsx
Normal 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
26
src/routes/Home.tsx
Normal 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
29
src/routes/PostDetail.tsx
Normal 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
46
src/routes/Profile.tsx
Normal 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
97
src/store/useAppStore.ts
Normal 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
13
src/types/Post.ts
Normal 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
9
src/types/User.ts
Normal 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
11
src/utils/fileHelpers.ts
Normal 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`
|
||||
}
|
||||
Reference in New Issue
Block a user