Apply UI updates: tags gap, notifications, card click, breadcrumb, move share input
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -6,7 +6,8 @@ import CreateUser from './routes/CreateUser'
|
||||
import PostDetail from './routes/PostDetail'
|
||||
import Navbar from './components/Navbar'
|
||||
import CreatePostModal from './components/CreatePostModal'
|
||||
import PDFModal from './components/PDFModal'
|
||||
import MarkdownModal from './components/MarkdownModal'
|
||||
import NotificationCenter from './components/NotificationCenter'
|
||||
import useAppStore from './store/useAppStore'
|
||||
|
||||
const App: React.FC = () => {
|
||||
@@ -20,8 +21,9 @@ const App: React.FC = () => {
|
||||
<div className="nav">
|
||||
<Navbar />
|
||||
</div>
|
||||
<NotificationCenter />
|
||||
<CreatePostModal />
|
||||
<PDFModal />
|
||||
<MarkdownModal />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, startTransition } from 'react'
|
||||
import useAppStore from '../store/useAppStore'
|
||||
import { generateRandomMarkdown } from '../utils/fileHelpers'
|
||||
import MarkdownPreview from './MarkdownPreview'
|
||||
|
||||
const CreatePostModal: React.FC = () => {
|
||||
const isOpen = useAppStore((s) => s.ui.isCreatePostOpen)
|
||||
@@ -8,28 +10,32 @@ const CreatePostModal: React.FC = () => {
|
||||
const createPost = useAppStore((s) => s.createPost)
|
||||
|
||||
const [content, setContent] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [attachedMarkdown, setAttachedMarkdown] = useState<{name:string,content:string} | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const onGenerate = () => {
|
||||
const md = generateRandomMarkdown()
|
||||
setAttachedMarkdown(md)
|
||||
}
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!currentUserId) {
|
||||
setError('Select a user first')
|
||||
return
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError('Content required')
|
||||
if (!content.trim() && !attachedMarkdown) {
|
||||
setError('Content or markdown required')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
startTransition(() => {
|
||||
const attachedPDF = file ? { name: file.name, url: URL.createObjectURL(file) } : undefined
|
||||
createPost({ authorId: currentUserId, content: content.trim(), attachedPDF })
|
||||
createPost({ authorId: currentUserId, content: content.trim(), attachedMarkdown: attachedMarkdown ?? undefined })
|
||||
toggle()
|
||||
setContent('')
|
||||
setFile(null)
|
||||
setAttachedMarkdown(null)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,14 +46,8 @@ const CreatePostModal: React.FC = () => {
|
||||
<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)
|
||||
}} />
|
||||
<button type="button" className="button" onClick={onGenerate}>Generate Markdown</button>
|
||||
{attachedMarkdown && <div style={{marginTop:8}}><MarkdownPreview content={attachedMarkdown.content} /></div>}
|
||||
</div>
|
||||
{error && <div style={{color:'red'}}>{error}</div>}
|
||||
<div style={{marginTop:8,display:'flex',justifyContent:'flex-end',gap:8}}>
|
||||
|
||||
27
src/components/MarkdownModal.tsx
Normal file
27
src/components/MarkdownModal.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import useAppStore from '../store/useAppStore'
|
||||
import MarkdownPreview from './MarkdownPreview'
|
||||
|
||||
const MarkdownModal: React.FC = () => {
|
||||
const selectedPostId = useAppStore((s) => s.selectedPostId)
|
||||
const setSelectedPost = useAppStore((s) => s.setSelectedPost)
|
||||
const post = useAppStore((s) => s.posts.find((p) => p.id === selectedPostId))
|
||||
|
||||
if (!selectedPostId || !post?.attachedMarkdown) return null
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={() => setSelectedPost(null)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
|
||||
<h3>{post.attachedMarkdown.name}</h3>
|
||||
<button onClick={() => setSelectedPost(null)}>Close</button>
|
||||
</div>
|
||||
<div style={{marginTop:8}}>
|
||||
<MarkdownPreview content={post.attachedMarkdown.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownModal
|
||||
32
src/components/MarkdownPreview.tsx
Normal file
32
src/components/MarkdownPreview.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const renderMarkdown = (md: string) => {
|
||||
if (!md) return ''
|
||||
let html = md
|
||||
// code blocks
|
||||
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
|
||||
// headings
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
// bold / italics
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
// links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
|
||||
// lists
|
||||
html = html.replace(/^\s*-\s+(.*)$/gim, '<li>$1</li>')
|
||||
// wrap list items
|
||||
html = html.replace(/(<li>[\s\S]*?<\/li>)/gms, '<ul>$1</ul>')
|
||||
// paragraphs
|
||||
html = html.replace(/\n{2,}/g, '</p><p>')
|
||||
html = `<p>${html}</p>`
|
||||
return html
|
||||
}
|
||||
|
||||
const MarkdownPreview: React.FC<{ content: string }> = ({ content }) => {
|
||||
const html = useMemo(() => renderMarkdown(content), [content])
|
||||
return <div className="markdown-preview" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
|
||||
export default MarkdownPreview
|
||||
22
src/components/NotificationCenter.tsx
Normal file
22
src/components/NotificationCenter.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import useAppStore from '../store/useAppStore'
|
||||
|
||||
const NotificationCenter: React.FC = () => {
|
||||
const notifications = useAppStore((s) => s.notifications)
|
||||
const removeNotification = useAppStore((s) => s.removeNotification)
|
||||
|
||||
if (!notifications || notifications.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="notif-container">
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id} className={`notif ${n.type ?? ''}`}>
|
||||
<div>{n.message}</div>
|
||||
<button onClick={() => removeNotification(n.id)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationCenter
|
||||
@@ -1,46 +1,106 @@
|
||||
import React, { memo } from 'react'
|
||||
import React, { memo, useState } 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'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { formatTime, generateToken } from '../utils/fileHelpers'
|
||||
import MarkdownPreview from './MarkdownPreview'
|
||||
|
||||
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)
|
||||
const setSelectedPost = useAppStore((s) => s.setSelectedPost)
|
||||
const currentUserId = useAppStore((s) => s.currentUserId)
|
||||
const addNotification = useAppStore((s) => s.addNotification)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [shareUrl, setShareUrl] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!author) return null
|
||||
|
||||
const handleEndorse = async (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
if (!currentUserId) {
|
||||
addNotification('Select a current user (top-right) to endorse from.', 'error')
|
||||
return
|
||||
}
|
||||
if (currentUserId === post.authorId) {
|
||||
addNotification("You can't endorse your own post.", 'error')
|
||||
return
|
||||
}
|
||||
endorsePost(post.id)
|
||||
const token = generateToken(6)
|
||||
const url = `https://arxiv.org/auth/endorse?x=${token}`
|
||||
setShareUrl(url)
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
addNotification('Share link copied to clipboard', 'success')
|
||||
} else {
|
||||
const area = document.createElement('textarea')
|
||||
area.value = url
|
||||
document.body.appendChild(area)
|
||||
area.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(area)
|
||||
setCopied(true)
|
||||
addNotification('Share link copied to clipboard', 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('copy failed', e)
|
||||
addNotification('Could not copy share link', 'error')
|
||||
}
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleCopy = async (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
if (!shareUrl) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
addNotification('Share link copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
addNotification('Could not copy share link', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card post-card" onClick={() => navigate(`/post/${post.id}`)} style={{cursor:'pointer'}}>
|
||||
<div style={{display:'flex',justifyContent:'space-between'}}>
|
||||
<div>
|
||||
<Link to={`/profile/${author.id}`}><strong>{author.name}</strong></Link>
|
||||
<Link to={`/profile/${author.id}`} onClick={(e) => e.stopPropagation()}><strong>{author.name}</strong></Link>
|
||||
<div className="small">{formatTime(post.createdAt)} ago</div>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:6}}>
|
||||
<div className="tags-row">
|
||||
{author.specialties.map((sp) => (
|
||||
<span key={sp} className="tag">{sp}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{marginTop:8}}>{post.content}</p>
|
||||
{post.attachedPDF && (
|
||||
{post.attachedMarkdown && (
|
||||
<div style={{marginTop:8}}>
|
||||
<div className="small">{post.attachedPDF.name}</div>
|
||||
<div style={{marginTop:6,display:'flex',gap:8}}>
|
||||
<button className="button" onClick={() => setSelectedPost(post.id)}>Open PDF</button>
|
||||
<PDFPreview url={post.attachedPDF.url} name={post.attachedPDF.name} />
|
||||
<div className="small">{post.attachedMarkdown.name}</div>
|
||||
<div style={{marginTop:6}}>
|
||||
<MarkdownPreview content={post.attachedMarkdown.content} />
|
||||
</div>
|
||||
</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>
|
||||
<button className="button" onClick={(e) => { e.stopPropagation(); handleEndorse(e); }}>Endorse Post ({post.endorsements})</button>
|
||||
<button className="button" style={{marginLeft:8}} onClick={(e) => { e.stopPropagation(); setSelectedPost(post.id); }}>Open MD</button>
|
||||
</div>
|
||||
</div>
|
||||
{shareUrl && (
|
||||
<div className="small" style={{marginTop:8}}>
|
||||
Share link: <a href={shareUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>{shareUrl}</a>
|
||||
<button style={{marginLeft:8}} onClick={(e) => { e.stopPropagation(); handleCopy(e); }}>{copied ? 'Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import React from 'react'
|
||||
import { User } from '../types/User'
|
||||
import { Link } from 'react-router-dom'
|
||||
import useAppStore from '../store/useAppStore'
|
||||
|
||||
const UserCard: React.FC<{ user: User }> = ({ user }) => {
|
||||
const currentUserId = useAppStore((s) => s.currentUserId)
|
||||
const endorseUser = useAppStore((s) => s.endorseUser)
|
||||
const addNotification = useAppStore((s) => s.addNotification)
|
||||
|
||||
const onEmailClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
if (!currentUserId) {
|
||||
addNotification('Select a current user (top-right) to endorse from.', 'error')
|
||||
return
|
||||
}
|
||||
if (currentUserId === user.id) {
|
||||
addNotification("You can't endorse yourself.", 'error')
|
||||
return
|
||||
}
|
||||
const specialty = user.specialties[0] ?? 'General'
|
||||
endorseUser(user.id, specialty)
|
||||
addNotification(`Endorsed ${user.name} for ${specialty}`, 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<Link to={`/profile/${user.id}`}><strong>{user.name}</strong></Link>
|
||||
<div className="small"><a href="#" onClick={onEmailClick}>{user.email}</a></div>
|
||||
<div className="small">{user.bio}</div>
|
||||
<div style={{marginTop:8}}>
|
||||
<div className="tags-row" style={{marginTop:8}}>
|
||||
{user.specialties.map((s) => (
|
||||
<span key={s} className="tag">{s} ({user.endorsements[s] ?? 0})</span>
|
||||
))}
|
||||
|
||||
@@ -15,7 +15,7 @@ body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,'He
|
||||
.post-card{border-radius:14px;padding:14px}
|
||||
.post-thumbs .thumb{width:120px;height:84px;background:linear-gradient(180deg,#f8fafc,#eef2ff);border-radius:10px}
|
||||
.avatar{width:56px;height:56px;border-radius:999px;background:#e6f0ff;color:#1e40af;display:flex;align-items:center;justify-content:center;font-weight:600}
|
||||
.tag{display:inline-flex;align-items:center;justify-content:center;height:32px;min-width:32px;padding:0 14px;background:#eef2ff;color:#1e40af;border-radius:999px;font-size:12px;margin-right:8px;white-space:nowrap}
|
||||
.tag{display:inline-flex;align-items:center;justify-content:center;height:32px;min-width:32px;padding:0 14px;background:#eef2ff;color:#1e40af;border-radius:999px;font-size:12px;margin-right:0;white-space:nowrap}
|
||||
.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}
|
||||
@@ -24,3 +24,24 @@ body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,'He
|
||||
.textarea{width:100%;min-height:100px;padding:8px;border:1px solid #e5e7eb;border-radius:6px}
|
||||
.create-input{display:flex;flex-direction:column}
|
||||
.rec{width:64px;height:64px;border-radius:12px;background:#f8fafc;display:flex;align-items:center;justify-content:center}
|
||||
|
||||
.markdown-preview{border:1px solid #eef2f6;padding:12px;border-radius:8px;background:#ffffff}
|
||||
|
||||
/* Create account styles */
|
||||
.create-account-bg{background:linear-gradient(180deg,#00c6ff 0%,#0072ff 100%)}
|
||||
.create-card{width:420px;margin:60px auto;padding:32px;background:#fff;border-radius:8px;text-align:center;box-shadow:0 20px 40px rgba(2,6,23,0.12)}
|
||||
.create-card h2{font-size:28px;color:#1e3a8a;margin-bottom:18px}
|
||||
.input{width:100%;padding:12px;border-radius:6px;border:1px solid #eef2f6;margin-bottom:12px}
|
||||
.btn-gradient{background:linear-gradient(90deg,#0ea5e9,#60a5fa);color:#fff;padding:12px 24px;border-radius:999px;border:none;cursor:pointer}
|
||||
.btn-ghost{background:#f1f5f9;padding:12px 24px;border-radius:999px;border:none;color:#475569;margin-left:12px}
|
||||
|
||||
/* Tags row spacing */
|
||||
.tags-row{display:flex;align-items:center;gap:5px;flex-wrap:wrap}
|
||||
|
||||
/* Notifications */
|
||||
.notif-container{position:fixed;top:16px;right:16px;z-index:1000;display:flex;flex-direction:column;gap:8px}
|
||||
.notif{background:#0f172a;color:#fff;padding:10px 14px;border-radius:8px;box-shadow:0 8px 20px rgba(2,6,23,0.2);min-width:220px;display:flex;justify-content:space-between;align-items:center}
|
||||
.notif.success{background:#15803d}
|
||||
.notif.error{background:#b91c1c}
|
||||
.notif button{background:transparent;border:none;color:inherit;cursor:pointer;padding:6px}
|
||||
|
||||
|
||||
@@ -55,9 +55,9 @@ export const seedUsers = (): User[] => {
|
||||
}
|
||||
|
||||
export const seedPosts = (users: User[]): Post[] => {
|
||||
const samplePDF = 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'
|
||||
const sampleMarkdown = `# Sample Paper\n\nThis is a sample markdown abstract.\n\n## Methods\n- Step 1\n- Step 2\n\n## Conclusion\nThis is a short conclusion.`
|
||||
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: 'p1', authorId: users[0].id, content: 'Working on a new transformer variant.', attachedMarkdown: { name: 'paper.md', content: sampleMarkdown }, 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 },
|
||||
@@ -65,7 +65,7 @@ export const seedPosts = (users: User[]): Post[] => {
|
||||
{ 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: 'p9', authorId: users[3].id, content: 'Preprint draft available.', attachedMarkdown: { name: 'draft.md', content: sampleMarkdown }, 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
|
||||
|
||||
@@ -5,42 +5,37 @@ import useAppStore from '../store/useAppStore'
|
||||
const CreateUser: React.FC = () => {
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [bio, setBio] = useState('')
|
||||
const [specialties, setSpecialties] = useState('')
|
||||
const createUser = useAppStore((s) => s.createUser)
|
||||
const addNotification = useAppStore((s) => s.addNotification)
|
||||
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')
|
||||
if (!name || !email || !bio || parsed.length === 0) {
|
||||
addNotification('All fields required and at least one specialty', 'error')
|
||||
return
|
||||
}
|
||||
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 className="create-account-bg" style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||
<div className="create-card">
|
||||
<h2>Create Account</h2>
|
||||
<form onSubmit={onSubmit} style={{display:'flex',flexDirection:'column',gap:12}}>
|
||||
<input className="input" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input className="input" placeholder="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<input type="password" className="input" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<textarea className="input" placeholder="Short bio" value={bio} onChange={(e) => setBio(e.target.value)} />
|
||||
<input className="input" placeholder="Specialties (comma separated)" value={specialties} onChange={(e) => setSpecialties(e.target.value)} />
|
||||
<div style={{display:'flex',justifyContent:'center',marginTop:8}}>
|
||||
<button className="btn-gradient" type="submit">SIGN UP</button>
|
||||
<button type="button" className="btn-ghost" onClick={() => navigate('/')}>SIGN IN</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ const Home: React.FC = () => {
|
||||
const currentUserId = useAppStore((s) => s.currentUserId)
|
||||
const suggestions = users.filter((u) => u.id !== currentUserId).slice(0, 3)
|
||||
|
||||
const toggleCreatePost = useAppStore((s) => s.toggleCreatePost)
|
||||
|
||||
return (
|
||||
<div className="feed-shell">
|
||||
<div className="container main-card" style={{display:'flex',gap:20,alignItems:'flex-start'}}>
|
||||
@@ -28,14 +30,15 @@ const Home: React.FC = () => {
|
||||
<div className="small">Recent · Friends · Popular</div>
|
||||
</header>
|
||||
|
||||
<Feed />
|
||||
|
||||
<div className="create-input card" style={{marginTop:12}}>
|
||||
<input placeholder="Share something..." style={{width:'100%',border:'none',outline:'none'}} />
|
||||
<div className="create-input card" style={{marginBottom:12}}>
|
||||
<input placeholder="Share something..." style={{width:'100%',border:'none',outline:'none'}} onFocus={() => toggleCreatePost()} />
|
||||
<div style={{display:'flex',justifyContent:'flex-end',marginTop:8}}>
|
||||
<button className="button">Send</button>
|
||||
<button className="button" onClick={() => toggleCreatePost()}>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Feed />
|
||||
|
||||
</main>
|
||||
|
||||
<aside className="right-column">
|
||||
|
||||
@@ -1,25 +1,74 @@
|
||||
import React from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import React, { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import useAppStore from '../store/useAppStore'
|
||||
import PDFPreview from '../components/PDFPreview'
|
||||
import MarkdownPreview from '../components/MarkdownPreview'
|
||||
import { generateToken } from '../utils/fileHelpers'
|
||||
|
||||
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)
|
||||
const currentUserId = useAppStore((s) => s.currentUserId)
|
||||
const addNotification = useAppStore((s) => s.addNotification)
|
||||
|
||||
const [shareUrl, setShareUrl] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!post) return <div className="card">Post not found</div>
|
||||
|
||||
const handleEndorse = async () => {
|
||||
if (!currentUserId) {
|
||||
addNotification('Select a current user (top-right) to endorse from.', 'error')
|
||||
return
|
||||
}
|
||||
if (currentUserId === post.authorId) {
|
||||
addNotification("You can't endorse your own post.", 'error')
|
||||
return
|
||||
}
|
||||
endorsePost(post.id)
|
||||
const token = generateToken(6)
|
||||
const url = `https://arxiv.org/auth/endorse?x=${token}`
|
||||
setShareUrl(url)
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
addNotification('Share link copied to clipboard', 'success')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!shareUrl) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
addNotification('Share link copied to clipboard', 'success')
|
||||
} catch {
|
||||
addNotification('Could not copy share link', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div style={{marginBottom:12}}>
|
||||
<Link to="/">← Home</Link>
|
||||
</div>
|
||||
<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} />}
|
||||
{post.attachedMarkdown && <MarkdownPreview content={post.attachedMarkdown.content} />}
|
||||
<div style={{marginTop:8}}>
|
||||
<button className="button" onClick={() => endorsePost(post.id)}>Endorse Post ({post.endorsements})</button>
|
||||
<button className="button" onClick={handleEndorse}>Endorse Post ({post.endorsements})</button>
|
||||
{shareUrl && (
|
||||
<div className="small" style={{marginTop:8}}>
|
||||
Share link: <a href={shareUrl} target="_blank" rel="noreferrer">{shareUrl}</a>
|
||||
<button style={{marginLeft:8}} onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,13 +13,30 @@ const Profile: React.FC = () => {
|
||||
const currentUserId = useAppStore((s) => s.currentUserId)
|
||||
const endorsementHistory = useAppStore((s) => s.endorsementHistory)
|
||||
const allUsers = useAppStore((s) => s.users)
|
||||
const addNotification = useAppStore((s) => s.addNotification)
|
||||
|
||||
if (!user) return <div className="card">User not found</div>
|
||||
|
||||
const onEmailClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
if (!currentUserId) {
|
||||
addNotification('Select a current user (top-right) to endorse from.', 'error')
|
||||
return
|
||||
}
|
||||
if (currentUserId === user.id) {
|
||||
addNotification("You can't endorse yourself.", 'error')
|
||||
return
|
||||
}
|
||||
const specialty = user.specialties[0] ?? 'General'
|
||||
endorseUser(user.id, specialty)
|
||||
addNotification(`Endorsed ${user.name} for ${specialty}`, 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card">
|
||||
<h2>{user.name}</h2>
|
||||
<div className="small"><a href="#" onClick={onEmailClick}>{user.email}</a></div>
|
||||
<div className="small">{user.bio}</div>
|
||||
<div style={{marginTop:8}}>
|
||||
{user.specialties.map((s) => (
|
||||
|
||||
@@ -5,22 +5,30 @@ import { seedUsers, seedPosts } from '../mock/seedData'
|
||||
|
||||
type UIState = { isCreatePostOpen: boolean }
|
||||
|
||||
type EndorsementHistoryItem = { id: string; type: 'user' | 'post'; by?: string | null; toUserId?: string; postId?: string; specialty?: string; createdAt: number }
|
||||
|
||||
type Notification = { id: string; message: string; type?: 'success' | 'error' | 'info'; createdAt: number }
|
||||
|
||||
type AppState = {
|
||||
users: User[]
|
||||
posts: Post[]
|
||||
currentUserId: string | null
|
||||
selectedPostId: string | null
|
||||
ui: UIState
|
||||
endorsementHistory: { id: string; type: 'user' | 'post'; by?: string | null; toUserId?: string; postId?: string; specialty?: string; createdAt: number }[]
|
||||
endorsementHistory: EndorsementHistoryItem[]
|
||||
notifications: Notification[]
|
||||
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
|
||||
createPost: (data: { authorId: string; content: string; attachedMarkdown?: { name: string; content: string } }) => Post
|
||||
endorseUser: (userId: string, specialty: string) => void
|
||||
endorsePost: (postId: string) => void
|
||||
attachPDFToPost: (postId: string, file: File) => void
|
||||
attachMarkdownToPost: (postId: string, md: { name: string; content: string }) => void
|
||||
setSelectedPost: (id: string | null) => void
|
||||
toggleCreatePost: () => void
|
||||
addNotification: (message: string, type?: 'success' | 'error' | 'info', duration?: number) => void
|
||||
removeNotification: (id: string) => void
|
||||
}
|
||||
|
||||
const makeId = () => (typeof crypto !== 'undefined' && 'randomUUID' in crypto ? (crypto as any).randomUUID() : 'id-' + Date.now())
|
||||
@@ -32,6 +40,7 @@ const useAppStore = create<AppState>((set, get) => ({
|
||||
selectedPostId: null,
|
||||
ui: { isCreatePostOpen: false },
|
||||
endorsementHistory: [],
|
||||
notifications: [],
|
||||
|
||||
seedData: () => {
|
||||
const users = seedUsers()
|
||||
@@ -62,7 +71,7 @@ const useAppStore = create<AppState>((set, get) => ({
|
||||
id,
|
||||
authorId: data.authorId,
|
||||
content: data.content,
|
||||
attachedPDF: data.attachedPDF,
|
||||
attachedMarkdown: data.attachedMarkdown,
|
||||
endorsements: 0,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
@@ -83,7 +92,6 @@ const useAppStore = create<AppState>((set, get) => ({
|
||||
}))
|
||||
},
|
||||
|
||||
|
||||
endorsePost: (postId) => {
|
||||
const by = get().currentUserId ?? null
|
||||
set((state) => ({
|
||||
@@ -92,7 +100,6 @@ const useAppStore = create<AppState>((set, get) => ({
|
||||
}))
|
||||
},
|
||||
|
||||
|
||||
attachPDFToPost: (postId, file) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
set((state) => ({
|
||||
@@ -100,9 +107,26 @@ const useAppStore = create<AppState>((set, get) => ({
|
||||
}))
|
||||
},
|
||||
|
||||
attachMarkdownToPost: (postId, md) => {
|
||||
set((state) => ({
|
||||
posts: state.posts.map((p) => (p.id === postId ? { ...p, attachedMarkdown: md } : p)),
|
||||
}))
|
||||
},
|
||||
|
||||
setSelectedPost: (id) => set(() => ({ selectedPostId: id })),
|
||||
|
||||
toggleCreatePost: () => set((state) => ({ ui: { isCreatePostOpen: !state.ui.isCreatePostOpen } })),
|
||||
|
||||
addNotification: (message, type = 'info', duration = 4000) => {
|
||||
const id = makeId()
|
||||
const notif = { id, message, type, createdAt: Date.now() }
|
||||
set((state) => ({ notifications: [notif, ...(state.notifications || [])] }))
|
||||
setTimeout(() => {
|
||||
set((state) => ({ notifications: (state.notifications || []).filter((n) => n.id !== id) }))
|
||||
}, duration)
|
||||
},
|
||||
|
||||
removeNotification: (id) => set((state) => ({ notifications: (get().notifications || []).filter((n) => n.id !== id) })),
|
||||
}))
|
||||
|
||||
export default useAppStore
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export interface AttachedPDF {
|
||||
export interface AttachedMarkdown {
|
||||
name: string
|
||||
url: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string
|
||||
authorId: string
|
||||
content: string
|
||||
attachedPDF?: AttachedPDF
|
||||
attachedMarkdown?: AttachedMarkdown
|
||||
endorsements: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
@@ -9,3 +9,24 @@ export const formatTime = (ts: number) => {
|
||||
const day = Math.floor(hr / 24)
|
||||
return `${day}d`
|
||||
}
|
||||
|
||||
export const generateToken = (length = 6) => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let out = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const generateRandomMarkdown = () => {
|
||||
const titles = ['On the Scalability of Models', 'A Study of Cognitive Load', 'Climate Data Methods', 'Quantum Signaling']
|
||||
const title = titles[Math.floor(Math.random() * titles.length)]
|
||||
const intro = 'This is a randomly generated abstract for demo purposes.'
|
||||
const methods = '- Data collection\n- Analysis\n- Validation'
|
||||
const results = 'Preliminary results indicate promising directions.'
|
||||
const conclusion = 'Conclusion: more work is required.'
|
||||
const content = `# ${title}\n\n${intro}\n\n## Methods\n${methods}\n\n## Results\n${results}\n\n## Conclusion\n${conclusion}`
|
||||
const name = `${title.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'')}.md`
|
||||
return { name, content }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user