Save workspace changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-15 11:20:49 -05:00
parent 556994a89d
commit 76dfc49b15
26 changed files with 4808 additions and 593 deletions

3812
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,23 +16,23 @@
"lint": "eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix"
},
"dependencies": {
"eslint-plugin-react-hooks": "^7.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.14.1",
"zustand": "^4"
},
"devDependencies": {
"typescript": "^5.5.0",
"vite": "^5.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"prettier": "^3.0.0",
"eslint": "^8.50.0",
"@typescript-eslint/parser": "^6.9.0",
"@typescript-eslint/eslint-plugin": "^6.9.0",
"@typescript-eslint/parser": "^6.9.0",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^8.50.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.9.0"
"prettier": "^3.0.0",
"typescript": "^5.5.0",
"vite": "^5.0.0"
}
}

View File

@@ -1,20 +1,20 @@
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 MarkdownModal from './components/MarkdownModal'
import NotificationCenter from './components/NotificationCenter'
import useAppStore from './store/useAppStore'
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 MarkdownModal from './components/MarkdownModal';
import NotificationCenter from './components/NotificationCenter';
import useAppStore from './store/useAppStore';
const App: React.FC = () => {
const seedData = useAppStore((s) => s.seedData)
const seedData = useAppStore((s) => s.seedData);
useEffect(() => {
seedData()
}, [seedData])
seedData();
}, [seedData]);
return (
<div className="app">
@@ -33,7 +33,7 @@ const App: React.FC = () => {
</Routes>
</main>
</div>
)
}
);
};
export default App
export default App;

View File

@@ -1,63 +1,84 @@
import React, { useState, startTransition } from 'react'
import useAppStore from '../store/useAppStore'
import { generateRandomMarkdown } from '../utils/fileHelpers'
import MarkdownPreview from './MarkdownPreview'
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)
const toggle = useAppStore((s) => s.toggleCreatePost)
const currentUserId = useAppStore((s) => s.currentUserId)
const createPost = useAppStore((s) => s.createPost)
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 [attachedMarkdown, setAttachedMarkdown] = useState<{name:string,content:string} | null>(null)
const [error, setError] = useState<string | null>(null)
const [content, setContent] = useState('');
const [attachedMarkdown, setAttachedMarkdown] = useState<{
name: string;
content: string;
} | null>(null);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null
if (!isOpen) return null;
const onGenerate = () => {
const md = generateRandomMarkdown()
setAttachedMarkdown(md)
}
const md = generateRandomMarkdown();
setAttachedMarkdown(md);
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (!currentUserId) {
setError('Select a user first')
return
setError('Select a user first');
return;
}
if (!content.trim() && !attachedMarkdown) {
setError('Content or markdown required')
return
setError('Content or markdown required');
return;
}
setError(null)
setError(null);
startTransition(() => {
createPost({ authorId: currentUserId, content: content.trim(), attachedMarkdown: attachedMarkdown ?? undefined })
toggle()
setContent('')
setAttachedMarkdown(null)
})
}
createPost({
authorId: currentUserId,
content: content.trim(),
attachedMarkdown: attachedMarkdown ?? undefined,
});
toggle();
setContent('');
setAttachedMarkdown(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}}>
<button type="button" className="button" onClick={onGenerate}>Generate Markdown</button>
{attachedMarkdown && <div style={{marginTop:8}}><MarkdownPreview content={attachedMarkdown.content} /></div>}
<textarea
className="textarea"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<div style={{ marginTop: 8 }}>
<button type="button" className="button" onClick={onGenerate}>
Generate Markdown
</button>
{attachedMarkdown && (
<div style={{ marginTop: 8 }}>
<MarkdownPreview content={attachedMarkdown.content} />
</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>
{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
export default CreatePostModal;

View File

@@ -1,11 +1,15 @@
import React from 'react'
import React from 'react';
const EndorseButton: React.FC<{ onClick: () => void; count: number; disabled?: boolean }> = ({ onClick, count, disabled }) => {
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
export default EndorseButton;

View File

@@ -1,18 +1,18 @@
import React from 'react'
import useAppStore from '../store/useAppStore'
import PostCard from './PostCard'
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>
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
export default Feed;

View File

@@ -1,27 +1,27 @@
import React from 'react'
import useAppStore from '../store/useAppStore'
import MarkdownPreview from './MarkdownPreview'
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))
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
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'}}>
<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}}>
<div style={{ marginTop: 8 }}>
<MarkdownPreview content={post.attachedMarkdown.content} />
</div>
</div>
</div>
)
}
);
};
export default MarkdownModal
export default MarkdownModal;

View File

@@ -1,32 +1,35 @@
import React, { useMemo } from 'react'
import React, { useMemo } from 'react';
const renderMarkdown = (md: string) => {
if (!md) return ''
let html = md
if (!md) return '';
let html = md;
// code blocks
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
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>')
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>')
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>')
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noreferrer">$1</a>'
);
// lists
html = html.replace(/^\s*-\s+(.*)$/gim, '<li>$1</li>')
html = html.replace(/^\s*-\s+(.*)$/gim, '<li>$1</li>');
// wrap list items
html = html.replace(/(<li>[\s\S]*?<\/li>)/gms, '<ul>$1</ul>')
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
}
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 }} />
}
const html = useMemo(() => renderMarkdown(content), [content]);
return <div className="markdown-preview" dangerouslySetInnerHTML={{ __html: html }} />;
};
export default MarkdownPreview
export default MarkdownPreview;

View File

@@ -1,30 +1,39 @@
import React from 'react'
import { Link } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
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)
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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Link to="/">Researcher Endorsement</Link>
<Link to="/create-user" style={{marginLeft:12}}>Create User</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)}>
<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>
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
<button className="button" onClick={() => toggleCreatePost()} disabled={!currentUserId}>Create Post</button>
<button className="button" onClick={() => toggleCreatePost()} disabled={!currentUserId}>
Create Post
</button>
</div>
</div>
)
}
);
};
export default Navbar
export default Navbar;

View File

@@ -1,11 +1,11 @@
import React from 'react'
import useAppStore from '../store/useAppStore'
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)
const notifications = useAppStore((s) => s.notifications);
const removeNotification = useAppStore((s) => s.removeNotification);
if (!notifications || notifications.length === 0) return null
if (!notifications || notifications.length === 0) return null;
return (
<div className="notif-container">
@@ -16,7 +16,7 @@ const NotificationCenter: React.FC = () => {
</div>
))}
</div>
)
}
);
};
export default NotificationCenter
export default NotificationCenter;

View File

@@ -1,27 +1,27 @@
import React from 'react'
import useAppStore from '../store/useAppStore'
import PDFPreview from './PDFPreview'
import React from 'react';
import useAppStore from '../store/useAppStore';
import PDFPreview from './PDFPreview';
const PDFModal: 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))
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?.attachedPDF) return null
if (!selectedPostId || !post?.attachedPDF) 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'}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3>{post.attachedPDF.name}</h3>
<button onClick={() => setSelectedPost(null)}>Close</button>
</div>
<div style={{marginTop:8}}>
<div style={{ marginTop: 8 }}>
<PDFPreview url={post.attachedPDF.url} name={post.attachedPDF.name} />
</div>
</div>
</div>
)
}
);
};
export default PDFModal
export default PDFModal;

View File

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

View File

@@ -1,108 +1,151 @@
import React, { memo, useState } from 'react'
import { Post } from '../types/Post'
import useAppStore from '../store/useAppStore'
import { Link, useNavigate } from 'react-router-dom'
import { formatTime, generateToken } from '../utils/fileHelpers'
import MarkdownPreview from './MarkdownPreview'
import React, { memo, useState } from 'react';
import { Post } from '../types/Post';
import useAppStore from '../store/useAppStore';
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 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)
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
if (!author) return null
if (!author) return null;
const handleEndorse = async (e?: React.MouseEvent) => {
e?.stopPropagation()
e?.stopPropagation();
if (!currentUserId) {
addNotification('Select a current user (top-right) to endorse from.', 'error')
return
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
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)
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')
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')
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)
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
e?.stopPropagation();
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl)
setCopied(true)
addNotification('Share link copied to clipboard', 'success')
setTimeout(() => setCopied(false), 2000)
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')
}
addNotification('Could not copy share link', 'error');
}
};
return (
<div className="card post-card" onClick={() => navigate(`/post/${post.id}`)} style={{cursor:'pointer'}}>
<div style={{display:'flex',justifyContent:'space-between'}}>
<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}`} onClick={(e) => e.stopPropagation()}><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 className="tags-row">
{author.specialties.map((sp) => (
<span key={sp} className="tag">{sp}</span>
<span key={sp} className="tag">
{sp}
</span>
))}
</div>
</div>
<p style={{marginTop:8}}>{post.content}</p>
<p style={{ marginTop: 8 }}>{post.content}</p>
{post.attachedMarkdown && (
<div style={{marginTop:8}}>
<div style={{ marginTop: 8 }}>
<div className="small">{post.attachedMarkdown.name}</div>
<div style={{marginTop:6}}>
<div style={{ marginTop: 6 }}>
<MarkdownPreview content={post.attachedMarkdown.content} />
</div>
</div>
)}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginTop:8}}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
}}
>
<div>
<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>
<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 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>
)
}
);
};
export default memo(PostCard)
export default memo(PostCard);

View File

@@ -1,40 +1,48 @@
import React from 'react'
import { User } from '../types/User'
import { Link } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
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 currentUserId = useAppStore((s) => s.currentUserId);
const endorseUser = useAppStore((s) => s.endorseUser);
const addNotification = useAppStore((s) => s.addNotification);
const onEmailClick = (e: React.MouseEvent) => {
e.preventDefault()
e.preventDefault();
if (!currentUserId) {
addNotification('Select a current user (top-right) to endorse from.', 'error')
return
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')
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>
<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 className="tags-row" 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>
<span key={s} className="tag">
{s} ({user.endorsements[s] ?? 0})
</span>
))}
</div>
</div>
)
}
);
};
export default UserCard
export default UserCard;

View File

@@ -1,12 +1,15 @@
import useAppStore from '../store/useAppStore'
import { User } from '../types/User'
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 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
export default useCurrentUser;

View File

@@ -1,47 +1,253 @@
/* Updated styling for three-column rounded feed */
:root{--max-width:1100px;--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:linear-gradient(135deg,#f7f6ff 0%,#f0fbff 100%);color:#111;padding:24px}
.feed-shell{min-height:100vh;padding:20px}
.container{max-width:var(--max-width);margin:0 auto}
.main-card{background:var(--card-bg);border-radius:20px;padding:18px;box-shadow:0 10px 30px rgba(16,24,40,0.08);border:1px solid rgba(0,0,0,0.04)}
.left-nav{width:200px}
.left-nav .logo{background:#111;color:#fff;padding:10px;border-radius:10px;font-weight:700;text-align:center}
.left-nav ul{list-style:none;padding:12px 0;margin:0}
.left-nav .nav-item{padding:10px 12px;border-radius:8px;margin-bottom:6px;cursor:pointer;color:#0f172a}
.left-nav .nav-item.active{background:#eef2ff;color:#1e40af}
.right-column{width:260px}
.card{background:var(--card-bg);border:1px solid #eaeef2;border-radius:12px;padding:12px;margin-bottom:12px}
.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: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}
.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(900px,95%)}
.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}
:root {
--max-width: 1100px;
--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: linear-gradient(135deg, #f7f6ff 0%, #f0fbff 100%);
color: #111;
padding: 24px;
}
.feed-shell {
min-height: 100vh;
padding: 20px;
}
.container {
max-width: var(--max-width);
margin: 0 auto;
}
.main-card {
background: var(--card-bg);
border-radius: 20px;
padding: 18px;
box-shadow: 0 10px 30px rgba(16, 24, 40, 0.08);
border: 1px solid rgba(0, 0, 0, 0.04);
}
.left-nav {
width: 200px;
}
.left-nav .logo {
background: #111;
color: #fff;
padding: 10px;
border-radius: 10px;
font-weight: 700;
text-align: center;
}
.left-nav ul {
list-style: none;
padding: 12px 0;
margin: 0;
}
.left-nav .nav-item {
padding: 10px 12px;
border-radius: 8px;
margin-bottom: 6px;
cursor: pointer;
color: #0f172a;
}
.left-nav .nav-item.active {
background: #eef2ff;
color: #1e40af;
}
.right-column {
width: 260px;
}
.card {
background: var(--card-bg);
border: 1px solid #eaeef2;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
}
.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: 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;
}
.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(900px, 95%);
}
.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}
.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}
.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}
.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}
.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;
}

View File

@@ -1,8 +1,8 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
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>
@@ -10,4 +10,4 @@ createRoot(document.getElementById('root')!).render(
<App />
</BrowserRouter>
</React.StrictMode>
)
);

View File

@@ -1,7 +1,7 @@
import { User } from '../types/User'
import { Post } from '../types/Post'
import { User } from '../types/User';
import { Post } from '../types/Post';
const now = Date.now()
const now = Date.now();
export const seedUsers = (): User[] => {
const users: User[] = [
@@ -34,7 +34,7 @@ export const seedUsers = (): User[] => {
},
{
id: 'u-quantum',
name: 'Liam O\'Connor',
name: "Liam O'Connor",
email: 'liam@example.com',
bio: 'Quantum information and condensed matter.',
specialties: ['Quantum Physics', 'Condensed Matter'],
@@ -50,23 +50,85 @@ export const seedUsers = (): User[] => {
endorsements: { Economics: 5 },
createdAt: now - 1000 * 60 * 60 * 24 * 6,
},
]
return users
}
];
return users;
};
export const seedPosts = (users: User[]): Post[] => {
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 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.', 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 },
{ 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.', 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
}
{
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,
},
{
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.',
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;
};

View File

@@ -1,46 +1,87 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
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 [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 [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)
e.preventDefault();
const parsed = specialties
.split(',')
.map((s) => s.trim())
.filter(Boolean);
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}`)
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="create-account-bg" style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center'}}>
<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>
<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>
</div>
)
}
);
};
export default CreateUser
export default CreateUser;

View File

@@ -1,18 +1,21 @@
import React from 'react'
import Feed from '../components/Feed'
import UserCard from '../components/UserCard'
import useAppStore from '../store/useAppStore'
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)
const users = useAppStore((s) => s.users);
const currentUserId = useAppStore((s) => s.currentUserId);
const suggestions = users.filter((u) => u.id !== currentUserId).slice(0, 3);
const toggleCreatePost = useAppStore((s) => s.toggleCreatePost)
const toggleCreatePost = useAppStore((s) => s.toggleCreatePost);
return (
<div className="feed-shell">
<div className="container main-card" style={{display:'flex',gap:20,alignItems:'flex-start'}}>
<div
className="container main-card"
style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}
>
<nav className="left-nav">
<div className="logo">RE</div>
<ul>
@@ -24,29 +27,41 @@ const Home: React.FC = () => {
</ul>
</nav>
<main className="main-column" style={{flex:1,maxWidth:680}}>
<header style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
<main className="main-column" style={{ flex: 1, maxWidth: 680 }}>
<header
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}
>
<h2>Feeds</h2>
<div className="small">Recent · Friends · Popular</div>
</header>
<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" onClick={() => toggleCreatePost()}>Send</button>
<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" onClick={() => toggleCreatePost()}>
Send
</button>
</div>
</div>
<Feed />
</main>
<aside className="right-column">
<section className="card">
<h4>Stories</h4>
<div style={{display:'flex',gap:8,marginTop:8}}>
{suggestions.map((u)=>(
<div key={u.id} style={{textAlign:'center'}}>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
{suggestions.map((u) => (
<div key={u.id} style={{ textAlign: 'center' }}>
<div className="avatar">{u.name[0]}</div>
<div className="small">{u.name.split(' ')[0]}</div>
</div>
@@ -54,26 +69,25 @@ const Home: React.FC = () => {
</div>
</section>
<section className="card" style={{marginTop:12}}>
<section className="card" style={{ marginTop: 12 }}>
<h4>Suggestions</h4>
{suggestions.map((u)=>(
{suggestions.map((u) => (
<UserCard key={u.id} user={u} />
))}
</section>
<section className="card" style={{marginTop:12}}>
<section className="card" style={{ marginTop: 12 }}>
<h4>Recommendations</h4>
<div style={{display:'flex',gap:8,marginTop:8}}>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<div className="rec">X</div>
<div className="rec">Y</div>
<div className="rec">Z</div>
</div>
</section>
</aside>
</div>
</div>
)
}
);
};
export default Home
export default Home;

View File

@@ -1,78 +1,85 @@
import React, { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
import MarkdownPreview from '../components/MarkdownPreview'
import { generateToken } from '../utils/fileHelpers'
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import useAppStore from '../store/useAppStore';
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 { 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)
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
if (!post) return <div className="card">Post not found</div>
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
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
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)
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')
await navigator.clipboard.writeText(url);
setCopied(true);
addNotification('Share link copied to clipboard', 'success');
} catch {
// ignore
}
}
};
const handleCopy = async () => {
if (!shareUrl) return
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
addNotification('Share link copied to clipboard', 'success')
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')
}
addNotification('Could not copy share link', 'error');
}
};
return (
<div className="container">
<div style={{marginBottom:12}}>
<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>
<div style={{ marginTop: 8 }}>{post.content}</div>
{post.attachedMarkdown && <MarkdownPreview content={post.attachedMarkdown.content} />}
<div style={{marginTop:8}}>
<button className="button" onClick={handleEndorse}>Endorse Post ({post.endorsements})</button>
<div style={{ marginTop: 8 }}>
<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 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>
)
}
);
};
export default PostDetail
export default PostDetail;

View File

@@ -1,50 +1,58 @@
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'
import { formatTime } from '../utils/fileHelpers'
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';
import { formatTime } from '../utils/fileHelpers';
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)
const endorsementHistory = useAppStore((s) => s.endorsementHistory)
const allUsers = useAppStore((s) => s.users)
const addNotification = useAppStore((s) => s.addNotification)
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);
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>
if (!user) return <div className="card">User not found</div>;
const onEmailClick = (e: React.MouseEvent) => {
e.preventDefault()
e.preventDefault();
if (!currentUserId) {
addNotification('Select a current user (top-right) to endorse from.', 'error')
return
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')
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">
<a href="#" onClick={onEmailClick}>
{user.email}
</a>
</div>
<div className="small">{user.bio}</div>
<div style={{marginTop:8}}>
<div style={{ marginTop: 8 }}>
{user.specialties.map((s) => (
<div key={s} style={{display:'flex',alignItems:'center',gap:8,marginTop:6}}>
<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} />
<EndorseButton
onClick={() => endorseUser(user.id, s)}
count={user.endorsements[s] ?? 0}
disabled={currentUserId === user.id}
/>
</div>
</div>
))}
@@ -52,17 +60,23 @@ const Profile: React.FC = () => {
</div>
{(() => {
const recent = endorsementHistory.filter((e) => e.type === 'user' && e.toUserId === id).slice(0,5)
if (recent.length === 0) return null
const recent = endorsementHistory
.filter((e) => e.type === 'user' && e.toUserId === id)
.slice(0, 5);
if (recent.length === 0) return null;
return (
<div className="card" style={{marginTop:8}}>
<div className="card" style={{ marginTop: 8 }}>
<h4>Recent endorsements</h4>
{recent.map((r) => {
const byName = allUsers.find((u) => u.id === r.by)?.name ?? 'Someone'
return <div key={r.id} className="small">{byName} endorsed {r.specialty} · {formatTime(r.createdAt)} ago</div>
const byName = allUsers.find((u) => u.id === r.by)?.name ?? 'Someone';
return (
<div key={r.id} className="small">
{byName} endorsed {r.specialty} · {formatTime(r.createdAt)} ago
</div>
);
})}
</div>
)
);
})()}
<h3>Posts</h3>
@@ -70,11 +84,11 @@ const Profile: React.FC = () => {
{posts.map((p) => (
<div key={p.id} className="card">
<UserCard user={user} />
<div style={{marginTop:8}}>{p.content}</div>
<div style={{ marginTop: 8 }}>{p.content}</div>
</div>
))}
</div>
)
}
);
};
export default Profile
export default Profile;

View File

@@ -1,37 +1,61 @@
import create from 'zustand'
import { User } from '../types/User'
import { Post } from '../types/Post'
import { seedUsers, seedPosts } from '../mock/seedData'
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 UIState = { isCreatePostOpen: boolean };
type EndorsementHistoryItem = { id: string; type: 'user' | 'post'; by?: string | null; toUserId?: string; postId?: string; specialty?: string; createdAt: number }
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 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: 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; 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
}
users: User[];
posts: Post[];
currentUserId: string | null;
selectedPostId: string | null;
ui: UIState;
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;
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())
const makeId = () =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: 'id-' + Date.now();
const useAppStore = create<AppState>((set, get) => ({
users: [],
@@ -43,13 +67,13 @@ const useAppStore = create<AppState>((set, get) => ({
notifications: [],
seedData: () => {
const users = seedUsers()
const posts = seedPosts(users)
set(() => ({ users, posts }))
const users = seedUsers();
const posts = seedPosts(users);
set(() => ({ users, posts }));
},
createUser: (data) => {
const id = makeId()
const id = makeId();
const newUser: User = {
id,
name: data.name,
@@ -58,15 +82,15 @@ const useAppStore = create<AppState>((set, get) => ({
specialties: data.specialties,
endorsements: {},
createdAt: Date.now(),
}
set((state) => ({ users: [newUser, ...state.users], currentUserId: id }))
return newUser
};
set((state) => ({ users: [newUser, ...state.users], currentUserId: id }));
return newUser;
},
setCurrentUser: (id) => set(() => ({ currentUserId: id })),
createPost: (data) => {
const id = makeId()
const id = makeId();
const newPost: Post = {
id,
authorId: data.authorId,
@@ -74,59 +98,71 @@ const useAppStore = create<AppState>((set, get) => ({
attachedMarkdown: data.attachedMarkdown,
endorsements: 0,
createdAt: Date.now(),
}
set((state) => ({ posts: [newPost, ...state.posts] }))
return newPost
};
set((state) => ({ posts: [newPost, ...state.posts] }));
return newPost;
},
endorseUser: (userId, specialty) => {
const by = get().currentUserId ?? null
const by = get().currentUserId ?? null;
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 }
if (u.id !== userId) return u;
const current = { ...u.endorsements };
current[specialty] = (current[specialty] || 0) + 1;
return { ...u, endorsements: current };
}),
endorsementHistory: [{ id: makeId(), type: 'user', by, toUserId: userId, specialty, createdAt: Date.now() }, ...(state.endorsementHistory || [])],
}))
endorsementHistory: [
{ id: makeId(), type: 'user', by, toUserId: userId, specialty, createdAt: Date.now() },
...(state.endorsementHistory || []),
],
}));
},
endorsePost: (postId) => {
const by = get().currentUserId ?? null
const by = get().currentUserId ?? null;
set((state) => ({
posts: state.posts.map((p) => (p.id === postId ? { ...p, endorsements: p.endorsements + 1 } : p)),
endorsementHistory: [{ id: makeId(), type: 'post', by, postId, createdAt: Date.now() }, ...(state.endorsementHistory || [])],
}))
posts: state.posts.map((p) =>
p.id === postId ? { ...p, endorsements: p.endorsements + 1 } : p
),
endorsementHistory: [
{ id: makeId(), type: 'post', by, postId, createdAt: Date.now() },
...(state.endorsementHistory || []),
],
}));
},
attachPDFToPost: (postId, file) => {
const url = URL.createObjectURL(file)
const url = URL.createObjectURL(file);
set((state) => ({
posts: state.posts.map((p) => (p.id === postId ? { ...p, attachedPDF: { name: file.name, url } } : p)),
}))
posts: state.posts.map((p) =>
p.id === postId ? { ...p, attachedPDF: { name: file.name, url } } : p
),
}));
},
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 } })),
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 || [])] }))
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)
set((state) => ({ notifications: (state.notifications || []).filter((n) => n.id !== id) }));
}, duration);
},
removeNotification: (id) => set((state) => ({ notifications: (get().notifications || []).filter((n) => n.id !== id) })),
}))
removeNotification: (id) =>
set((state) => ({ notifications: (get().notifications || []).filter((n) => n.id !== id) })),
}));
export default useAppStore
export default useAppStore;

View File

@@ -1,13 +1,13 @@
export interface AttachedMarkdown {
name: string
content: string
name: string;
content: string;
}
export interface Post {
id: string
authorId: string
content: string
attachedMarkdown?: AttachedMarkdown
endorsements: number
createdAt: number
id: string;
authorId: string;
content: string;
attachedMarkdown?: AttachedMarkdown;
endorsements: number;
createdAt: number;
}

View File

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

View File

@@ -1,32 +1,40 @@
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`
}
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`;
};
export const generateToken = (length = 6) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let out = ''
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let out = '';
for (let i = 0; i < length; i++) {
out += chars[Math.floor(Math.random() * chars.length)]
out += chars[Math.floor(Math.random() * chars.length)];
}
return out
}
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 }
}
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 };
};