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" "lint": "eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix"
}, },
"dependencies": { "dependencies": {
"eslint-plugin-react-hooks": "^7.0.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^6.14.1", "react-router-dom": "^6.14.1",
"zustand": "^4" "zustand": "^4"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.0",
"vite": "^5.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^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/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-config-prettier": "^9.0.0",
"eslint-plugin-react": "^7.32.2", "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 React, { useEffect } from 'react';
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from 'react-router-dom';
import Home from './routes/Home' import Home from './routes/Home';
import Profile from './routes/Profile' import Profile from './routes/Profile';
import CreateUser from './routes/CreateUser' import CreateUser from './routes/CreateUser';
import PostDetail from './routes/PostDetail' import PostDetail from './routes/PostDetail';
import Navbar from './components/Navbar' import Navbar from './components/Navbar';
import CreatePostModal from './components/CreatePostModal' import CreatePostModal from './components/CreatePostModal';
import MarkdownModal from './components/MarkdownModal' import MarkdownModal from './components/MarkdownModal';
import NotificationCenter from './components/NotificationCenter' import NotificationCenter from './components/NotificationCenter';
import useAppStore from './store/useAppStore' import useAppStore from './store/useAppStore';
const App: React.FC = () => { const App: React.FC = () => {
const seedData = useAppStore((s) => s.seedData) const seedData = useAppStore((s) => s.seedData);
useEffect(() => { useEffect(() => {
seedData() seedData();
}, [seedData]) }, [seedData]);
return ( return (
<div className="app"> <div className="app">
@@ -33,7 +33,7 @@ const App: React.FC = () => {
</Routes> </Routes>
</main> </main>
</div> </div>
) );
} };
export default App export default App;

View File

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

View File

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

View File

@@ -1,27 +1,27 @@
import React from 'react' import React from 'react';
import useAppStore from '../store/useAppStore' import useAppStore from '../store/useAppStore';
import MarkdownPreview from './MarkdownPreview' import MarkdownPreview from './MarkdownPreview';
const MarkdownModal: React.FC = () => { const MarkdownModal: React.FC = () => {
const selectedPostId = useAppStore((s) => s.selectedPostId) const selectedPostId = useAppStore((s) => s.selectedPostId);
const setSelectedPost = useAppStore((s) => s.setSelectedPost) const setSelectedPost = useAppStore((s) => s.setSelectedPost);
const post = useAppStore((s) => s.posts.find((p) => p.id === selectedPostId)) const post = useAppStore((s) => s.posts.find((p) => p.id === selectedPostId));
if (!selectedPostId || !post?.attachedMarkdown) return null if (!selectedPostId || !post?.attachedMarkdown) return null;
return ( return (
<div className="modal-backdrop" onClick={() => setSelectedPost(null)}> <div className="modal-backdrop" onClick={() => setSelectedPost(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <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> <h3>{post.attachedMarkdown.name}</h3>
<button onClick={() => setSelectedPost(null)}>Close</button> <button onClick={() => setSelectedPost(null)}>Close</button>
</div> </div>
<div style={{marginTop:8}}> <div style={{ marginTop: 8 }}>
<MarkdownPreview content={post.attachedMarkdown.content} /> <MarkdownPreview content={post.attachedMarkdown.content} />
</div> </div>
</div> </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) => { const renderMarkdown = (md: string) => {
if (!md) return '' if (!md) return '';
let html = md let html = md;
// code blocks // 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 // headings
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>') html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>') html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>') html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
// bold / italics // bold / italics
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>') html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
// links // 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 // lists
html = html.replace(/^\s*-\s+(.*)$/gim, '<li>$1</li>') html = html.replace(/^\s*-\s+(.*)$/gim, '<li>$1</li>');
// wrap list items // 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 // paragraphs
html = html.replace(/\n{2,}/g, '</p><p>') html = html.replace(/\n{2,}/g, '</p><p>');
html = `<p>${html}</p>` html = `<p>${html}</p>`;
return html return html;
} };
const MarkdownPreview: React.FC<{ content: string }> = ({ content }) => { const MarkdownPreview: React.FC<{ content: string }> = ({ content }) => {
const html = useMemo(() => renderMarkdown(content), [content]) const html = useMemo(() => renderMarkdown(content), [content]);
return <div className="markdown-preview" dangerouslySetInnerHTML={{ __html: html }} /> 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 React from 'react';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import useAppStore from '../store/useAppStore' import useAppStore from '../store/useAppStore';
const Navbar: React.FC = () => { const Navbar: React.FC = () => {
const users = useAppStore((s) => s.users) const users = useAppStore((s) => s.users);
const currentUserId = useAppStore((s) => s.currentUserId) const currentUserId = useAppStore((s) => s.currentUserId);
const setCurrentUser = useAppStore((s) => s.setCurrentUser) const setCurrentUser = useAppStore((s) => s.setCurrentUser);
const toggleCreatePost = useAppStore((s) => s.toggleCreatePost) const toggleCreatePost = useAppStore((s) => s.toggleCreatePost);
return ( return (
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Link to="/">Researcher Endorsement</Link> <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>
<div style={{display:'flex',alignItems:'center',gap:12}}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<select value={currentUserId ?? ''} onChange={(e) => setCurrentUser(e.target.value || null)}> <select
value={currentUserId ?? ''}
onChange={(e) => setCurrentUser(e.target.value || null)}
>
<option value="">(No user)</option> <option value="">(No user)</option>
{users.map((u) => ( {users.map((u) => (
<option key={u.id} value={u.id}>{u.name}</option> <option key={u.id} value={u.id}>
{u.name}
</option>
))} ))}
</select> </select>
<button className="button" onClick={() => toggleCreatePost()} disabled={!currentUserId}>Create Post</button> <button className="button" onClick={() => toggleCreatePost()} disabled={!currentUserId}>
Create Post
</button>
</div> </div>
</div> </div>
) );
} };
export default Navbar export default Navbar;

View File

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

View File

@@ -1,27 +1,27 @@
import React from 'react' import React from 'react';
import useAppStore from '../store/useAppStore' import useAppStore from '../store/useAppStore';
import PDFPreview from './PDFPreview' import PDFPreview from './PDFPreview';
const PDFModal: React.FC = () => { const PDFModal: React.FC = () => {
const selectedPostId = useAppStore((s) => s.selectedPostId) const selectedPostId = useAppStore((s) => s.selectedPostId);
const setSelectedPost = useAppStore((s) => s.setSelectedPost) const setSelectedPost = useAppStore((s) => s.setSelectedPost);
const post = useAppStore((s) => s.posts.find((p) => p.id === selectedPostId)) const post = useAppStore((s) => s.posts.find((p) => p.id === selectedPostId));
if (!selectedPostId || !post?.attachedPDF) return null if (!selectedPostId || !post?.attachedPDF) return null;
return ( return (
<div className="modal-backdrop" onClick={() => setSelectedPost(null)}> <div className="modal-backdrop" onClick={() => setSelectedPost(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <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> <h3>{post.attachedPDF.name}</h3>
<button onClick={() => setSelectedPost(null)}>Close</button> <button onClick={() => setSelectedPost(null)}>Close</button>
</div> </div>
<div style={{marginTop:8}}> <div style={{ marginTop: 8 }}>
<PDFPreview url={post.attachedPDF.url} name={post.attachedPDF.name} /> <PDFPreview url={post.attachedPDF.url} name={post.attachedPDF.name} />
</div> </div>
</div> </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 }) => { const PDFPreview: React.FC<{ url: string; name?: string }> = ({ url, name }) => {
useEffect(() => { useEffect(() => {
return () => { return () => {
// only revoke blob URLs // only revoke blob URLs
try { try {
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url) if (url && url.startsWith('blob:')) URL.revokeObjectURL(url);
} catch (e) { } catch (e) {
// ignore // ignore
} }
} };
}, [url]) }, [url]);
return ( return (
<div> <div>
<div className="small">{name}</div> <div className="small">{name}</div>
<iframe src={url} width="100%" height={300} title={name ?? 'pdf'} /> <iframe src={url} width="100%" height={300} title={name ?? 'pdf'} />
</div> </div>
) );
} };
export default PDFPreview export default PDFPreview;

View File

@@ -1,108 +1,151 @@
import React, { memo, useState } from 'react' import React, { memo, useState } from 'react';
import { Post } from '../types/Post' import { Post } from '../types/Post';
import useAppStore from '../store/useAppStore' import useAppStore from '../store/useAppStore';
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom';
import { formatTime, generateToken } from '../utils/fileHelpers' import { formatTime, generateToken } from '../utils/fileHelpers';
import MarkdownPreview from './MarkdownPreview' import MarkdownPreview from './MarkdownPreview';
const PostCard: React.FC<{ post: Post }> = ({ post }) => { const PostCard: React.FC<{ post: Post }> = ({ post }) => {
const author = useAppStore((s) => s.users.find((u) => u.id === post.authorId)) const author = useAppStore((s) => s.users.find((u) => u.id === post.authorId));
const endorsePost = useAppStore((s) => s.endorsePost) const endorsePost = useAppStore((s) => s.endorsePost);
const setSelectedPost = useAppStore((s) => s.setSelectedPost) const setSelectedPost = useAppStore((s) => s.setSelectedPost);
const currentUserId = useAppStore((s) => s.currentUserId) const currentUserId = useAppStore((s) => s.currentUserId);
const addNotification = useAppStore((s) => s.addNotification) const addNotification = useAppStore((s) => s.addNotification);
const navigate = useNavigate() const navigate = useNavigate();
const [shareUrl, setShareUrl] = useState<string | null>(null) const [shareUrl, setShareUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false);
if (!author) return null if (!author) return null;
const handleEndorse = async (e?: React.MouseEvent) => { const handleEndorse = async (e?: React.MouseEvent) => {
e?.stopPropagation() e?.stopPropagation();
if (!currentUserId) { if (!currentUserId) {
addNotification('Select a current user (top-right) to endorse from.', 'error') addNotification('Select a current user (top-right) to endorse from.', 'error');
return return;
} }
if (currentUserId === post.authorId) { if (currentUserId === post.authorId) {
addNotification("You can't endorse your own post.", 'error') addNotification("You can't endorse your own post.", 'error');
return return;
} }
endorsePost(post.id) endorsePost(post.id);
const token = generateToken(6) const token = generateToken(6);
const url = `https://arxiv.org/auth/endorse?x=${token}` const url = `https://arxiv.org/auth/endorse?x=${token}`;
setShareUrl(url) setShareUrl(url);
try { try {
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url) await navigator.clipboard.writeText(url);
setCopied(true) setCopied(true);
addNotification('Share link copied to clipboard', 'success') addNotification('Share link copied to clipboard', 'success');
} else { } else {
const area = document.createElement('textarea') const area = document.createElement('textarea');
area.value = url area.value = url;
document.body.appendChild(area) document.body.appendChild(area);
area.select() area.select();
document.execCommand('copy') document.execCommand('copy');
document.body.removeChild(area) document.body.removeChild(area);
setCopied(true) setCopied(true);
addNotification('Share link copied to clipboard', 'success') addNotification('Share link copied to clipboard', 'success');
} }
} catch (e) { } catch (e) {
console.error('copy failed', e) console.error('copy failed', e);
addNotification('Could not copy share link', 'error') addNotification('Could not copy share link', 'error');
} }
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000);
} };
const handleCopy = async (e?: React.MouseEvent) => { const handleCopy = async (e?: React.MouseEvent) => {
e?.stopPropagation() e?.stopPropagation();
if (!shareUrl) return if (!shareUrl) return;
try { try {
await navigator.clipboard.writeText(shareUrl) await navigator.clipboard.writeText(shareUrl);
setCopied(true) setCopied(true);
addNotification('Share link copied to clipboard', 'success') addNotification('Share link copied to clipboard', 'success');
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000);
} catch { } catch {
addNotification('Could not copy share link', 'error') addNotification('Could not copy share link', 'error');
} }
} };
return ( return (
<div className="card post-card" onClick={() => navigate(`/post/${post.id}`)} style={{cursor:'pointer'}}> <div
<div style={{display:'flex',justifyContent:'space-between'}}> className="card post-card"
onClick={() => navigate(`/post/${post.id}`)}
style={{ cursor: 'pointer' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div> <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 className="small">{formatTime(post.createdAt)} ago</div>
</div> </div>
<div className="tags-row"> <div className="tags-row">
{author.specialties.map((sp) => ( {author.specialties.map((sp) => (
<span key={sp} className="tag">{sp}</span> <span key={sp} className="tag">
{sp}
</span>
))} ))}
</div> </div>
</div> </div>
<p style={{marginTop:8}}>{post.content}</p> <p style={{ marginTop: 8 }}>{post.content}</p>
{post.attachedMarkdown && ( {post.attachedMarkdown && (
<div style={{marginTop:8}}> <div style={{ marginTop: 8 }}>
<div className="small">{post.attachedMarkdown.name}</div> <div className="small">{post.attachedMarkdown.name}</div>
<div style={{marginTop:6}}> <div style={{ marginTop: 6 }}>
<MarkdownPreview content={post.attachedMarkdown.content} /> <MarkdownPreview content={post.attachedMarkdown.content} />
</div> </div>
</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> <div>
<button className="button" onClick={(e) => { e.stopPropagation(); handleEndorse(e); }}>Endorse Post ({post.endorsements})</button> <button
<button className="button" style={{marginLeft:8}} onClick={(e) => { e.stopPropagation(); setSelectedPost(post.id); }}>Open MD</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>
</div> </div>
{shareUrl && ( {shareUrl && (
<div className="small" style={{marginTop:8}}> <div className="small" style={{ marginTop: 8 }}>
Share link: <a href={shareUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>{shareUrl}</a> Share link:{' '}
<button style={{marginLeft:8}} onClick={(e) => { e.stopPropagation(); handleCopy(e); }}>{copied ? 'Copied' : 'Copy'}</button> <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>
)} )}
</div> </div>
) );
} };
export default memo(PostCard) export default memo(PostCard);

View File

@@ -1,40 +1,48 @@
import React from 'react' import React from 'react';
import { User } from '../types/User' import { User } from '../types/User';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import useAppStore from '../store/useAppStore' import useAppStore from '../store/useAppStore';
const UserCard: React.FC<{ user: User }> = ({ user }) => { const UserCard: React.FC<{ user: User }> = ({ user }) => {
const currentUserId = useAppStore((s) => s.currentUserId) const currentUserId = useAppStore((s) => s.currentUserId);
const endorseUser = useAppStore((s) => s.endorseUser) const endorseUser = useAppStore((s) => s.endorseUser);
const addNotification = useAppStore((s) => s.addNotification) const addNotification = useAppStore((s) => s.addNotification);
const onEmailClick = (e: React.MouseEvent) => { const onEmailClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault();
if (!currentUserId) { if (!currentUserId) {
addNotification('Select a current user (top-right) to endorse from.', 'error') addNotification('Select a current user (top-right) to endorse from.', 'error');
return return;
} }
if (currentUserId === user.id) { if (currentUserId === user.id) {
addNotification("You can't endorse yourself.", 'error') addNotification("You can't endorse yourself.", 'error');
return return;
} }
const specialty = user.specialties[0] ?? 'General' const specialty = user.specialties[0] ?? 'General';
endorseUser(user.id, specialty) endorseUser(user.id, specialty);
addNotification(`Endorsed ${user.name} for ${specialty}`, 'success') addNotification(`Endorsed ${user.name} for ${specialty}`, 'success');
} };
return ( return (
<div className="card"> <div className="card">
<Link to={`/profile/${user.id}`}><strong>{user.name}</strong></Link> <Link to={`/profile/${user.id}`}>
<div className="small"><a href="#" onClick={onEmailClick}>{user.email}</a></div> <strong>{user.name}</strong>
</Link>
<div className="small">
<a href="#" onClick={onEmailClick}>
{user.email}
</a>
</div>
<div className="small">{user.bio}</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) => ( {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>
</div> </div>
) );
} };
export default UserCard export default UserCard;

View File

@@ -1,12 +1,15 @@
import useAppStore from '../store/useAppStore' import useAppStore from '../store/useAppStore';
import { User } from '../types/User' import { User } from '../types/User';
export const useCurrentUser = (): { currentUser: User | null; setCurrentUser: (id: string | null) => void } => { export const useCurrentUser = (): {
const currentUserId = useAppStore((s) => s.currentUserId) currentUser: User | null;
const users = useAppStore((s) => s.users) setCurrentUser: (id: string | null) => void;
const setCurrentUser = useAppStore((s) => s.setCurrentUser) } => {
const currentUser = users.find((u) => u.id === currentUserId) ?? null const currentUserId = useAppStore((s) => s.currentUserId);
return { currentUser, setCurrentUser } 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 */ /* Updated styling for three-column rounded feed */
:root{--max-width:1100px;--card-bg:#fff;--muted:#6b7280} :root {
*{box-sizing:border-box} --max-width: 1100px;
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} --card-bg: #fff;
.feed-shell{min-height:100vh;padding:20px} --muted: #6b7280;
.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} box-sizing: border-box;
.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} body {
.left-nav .nav-item{padding:10px 12px;border-radius:8px;margin-bottom:6px;cursor:pointer;color:#0f172a} font-family:
.left-nav .nav-item.active{background:#eef2ff;color:#1e40af} Inter,
.right-column{width:260px} ui-sans-serif,
.card{background:var(--card-bg);border:1px solid #eaeef2;border-radius:12px;padding:12px;margin-bottom:12px} system-ui,
.post-card{border-radius:14px;padding:14px} -apple-system,
.post-thumbs .thumb{width:120px;height:84px;background:linear-gradient(180deg,#f8fafc,#eef2ff);border-radius:10px} Segoe UI,
.avatar{width:56px;height:56px;border-radius:999px;background:#e6f0ff;color:#1e40af;display:flex;align-items:center;justify-content:center;font-weight:600} Roboto,
.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} 'Helvetica Neue',
.button{background:#2563eb;color:#fff;border:none;padding:8px 12px;border-radius:6px;cursor:pointer} Arial;
.small{font-size:13px;color:var(--muted)} line-height: 1.4;
.sidebar{width:300px} margin: 0;
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center} background: linear-gradient(135deg, #f7f6ff 0%, #f0fbff 100%);
.modal{background:#fff;border-radius:8px;padding:16px;width:min(900px,95%)} color: #111;
.textarea{width:100%;min-height:100px;padding:8px;border:1px solid #e5e7eb;border-radius:6px} padding: 24px;
.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} .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 styles */
.create-account-bg{background:linear-gradient(180deg,#00c6ff 0%,#0072ff 100%)} .create-account-bg {
.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)} background: linear-gradient(180deg, #00c6ff 0%, #0072ff 100%);
.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} .create-card {
.btn-gradient{background:linear-gradient(90deg,#0ea5e9,#60a5fa);color:#fff;padding:12px 24px;border-radius:999px;border:none;cursor:pointer} width: 420px;
.btn-ghost{background:#f1f5f9;padding:12px 24px;border-radius:999px;border:none;color:#475569;margin-left:12px} 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 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 */ /* Notifications */
.notif-container{position:fixed;top:16px;right:16px;z-index:1000;display:flex;flex-direction:column;gap:8px} .notif-container {
.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} position: fixed;
.notif.success{background:#15803d} top: 16px;
.notif.error{background:#b91c1c} right: 16px;
.notif button{background:transparent;border:none;color:inherit;cursor:pointer;padding:6px} 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 React from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom';
import App from './App' import App from './App';
import './index.css' import './index.css';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
@@ -10,4 +10,4 @@ createRoot(document.getElementById('root')!).render(
<App /> <App />
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>
) );

View File

@@ -1,7 +1,7 @@
import { User } from '../types/User' import { User } from '../types/User';
import { Post } from '../types/Post' import { Post } from '../types/Post';
const now = Date.now() const now = Date.now();
export const seedUsers = (): User[] => { export const seedUsers = (): User[] => {
const users: User[] = [ const users: User[] = [
@@ -34,7 +34,7 @@ export const seedUsers = (): User[] => {
}, },
{ {
id: 'u-quantum', id: 'u-quantum',
name: 'Liam O\'Connor', name: "Liam O'Connor",
email: 'liam@example.com', email: 'liam@example.com',
bio: 'Quantum information and condensed matter.', bio: 'Quantum information and condensed matter.',
specialties: ['Quantum Physics', 'Condensed Matter'], specialties: ['Quantum Physics', 'Condensed Matter'],
@@ -50,23 +50,85 @@ export const seedUsers = (): User[] => {
endorsements: { Economics: 5 }, endorsements: { Economics: 5 },
createdAt: now - 1000 * 60 * 60 * 24 * 6, createdAt: now - 1000 * 60 * 60 * 24 * 6,
}, },
] ];
return users return users;
} };
export const seedPosts = (users: User[]): Post[] => { 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[] = [ 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: 'p1',
{ id: 'p3', authorId: users[2].id, content: 'Dataset release for coastal temperature anomalies.', endorsements: 0, createdAt: now - 1000 * 60 * 60 * 5 }, authorId: users[0].id,
{ id: 'p4', authorId: users[3].id, content: 'Simulations of topological phases.', endorsements: 3, createdAt: now - 1000 * 60 * 60 * 24 }, content: 'Working on a new transformer variant.',
{ id: 'p5', authorId: users[4].id, content: 'Market design experiment planned next month.', endorsements: 4, createdAt: now - 1000 * 60 * 60 * 24 * 2 }, attachedMarkdown: { name: 'paper.md', content: sampleMarkdown },
{ id: 'p6', authorId: users[0].id, content: 'Trying a new optimization schedule.', endorsements: 1, createdAt: now - 1000 * 60 * 60 * 3 }, endorsements: 2,
{ id: 'p7', authorId: users[1].id, content: 'Open-source code for preprocessing.', endorsements: 2, createdAt: now - 1000 * 60 * 60 * 6 }, createdAt: now - 1000 * 60 * 30,
{ 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 }, id: 'p2',
] authorId: users[1].id,
return posts 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 React, { useState } from 'react';
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom';
import useAppStore from '../store/useAppStore' import useAppStore from '../store/useAppStore';
const CreateUser: React.FC = () => { const CreateUser: React.FC = () => {
const [name, setName] = useState('') const [name, setName] = useState('');
const [email, setEmail] = useState('') const [email, setEmail] = useState('');
const [password, setPassword] = useState('') const [password, setPassword] = useState('');
const [bio, setBio] = useState('') const [bio, setBio] = useState('');
const [specialties, setSpecialties] = useState('') const [specialties, setSpecialties] = useState('');
const createUser = useAppStore((s) => s.createUser) const createUser = useAppStore((s) => s.createUser);
const addNotification = useAppStore((s) => s.addNotification) const addNotification = useAppStore((s) => s.addNotification);
const navigate = useNavigate() const navigate = useNavigate();
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
const parsed = specialties.split(',').map((s) => s.trim()).filter(Boolean) const parsed = specialties
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (!name || !email || !bio || parsed.length === 0) { if (!name || !email || !bio || parsed.length === 0) {
addNotification('All fields required and at least one specialty', 'error') addNotification('All fields required and at least one specialty', 'error');
return return;
} }
const newUser = createUser({ name, email, bio, specialties: parsed }) const newUser = createUser({ name, email, bio, specialties: parsed });
navigate(`/profile/${newUser.id}`) navigate(`/profile/${newUser.id}`);
} };
return ( 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"> <div className="create-card">
<h2>Create Account</h2> <h2>Create Account</h2>
<form onSubmit={onSubmit} style={{display:'flex',flexDirection:'column',gap:12}}> <form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input className="input" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} /> <input
<input className="input" placeholder="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} /> className="input"
<input type="password" className="input" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} /> placeholder="Name"
<textarea className="input" placeholder="Short bio" value={bio} onChange={(e) => setBio(e.target.value)} /> value={name}
<input className="input" placeholder="Specialties (comma separated)" value={specialties} onChange={(e) => setSpecialties(e.target.value)} /> onChange={(e) => setName(e.target.value)}
<div style={{display:'flex',justifyContent:'center',marginTop:8}}> />
<button className="btn-gradient" type="submit">SIGN UP</button> <input
<button type="button" className="btn-ghost" onClick={() => navigate('/')}>SIGN IN</button> 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> </div>
</form> </form>
</div> </div>
</div> </div>
) );
} };
export default CreateUser export default CreateUser;

View File

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

View File

@@ -1,78 +1,85 @@
import React, { useState } from 'react' import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom';
import useAppStore from '../store/useAppStore' import useAppStore from '../store/useAppStore';
import MarkdownPreview from '../components/MarkdownPreview' import MarkdownPreview from '../components/MarkdownPreview';
import { generateToken } from '../utils/fileHelpers' import { generateToken } from '../utils/fileHelpers';
const PostDetail: React.FC = () => { const PostDetail: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>();
const post = useAppStore((s) => s.posts.find((p) => p.id === id)) const post = useAppStore((s) => s.posts.find((p) => p.id === id));
const author = useAppStore((s) => s.users.find((u) => u.id === post?.authorId)) const author = useAppStore((s) => s.users.find((u) => u.id === post?.authorId));
const endorsePost = useAppStore((s) => s.endorsePost) const endorsePost = useAppStore((s) => s.endorsePost);
const currentUserId = useAppStore((s) => s.currentUserId) const currentUserId = useAppStore((s) => s.currentUserId);
const addNotification = useAppStore((s) => s.addNotification) const addNotification = useAppStore((s) => s.addNotification);
const [shareUrl, setShareUrl] = useState<string | null>(null) const [shareUrl, setShareUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false) 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 () => { const handleEndorse = async () => {
if (!currentUserId) { if (!currentUserId) {
addNotification('Select a current user (top-right) to endorse from.', 'error') addNotification('Select a current user (top-right) to endorse from.', 'error');
return return;
} }
if (currentUserId === post.authorId) { if (currentUserId === post.authorId) {
addNotification("You can't endorse your own post.", 'error') addNotification("You can't endorse your own post.", 'error');
return return;
} }
endorsePost(post.id) endorsePost(post.id);
const token = generateToken(6) const token = generateToken(6);
const url = `https://arxiv.org/auth/endorse?x=${token}` const url = `https://arxiv.org/auth/endorse?x=${token}`;
setShareUrl(url) setShareUrl(url);
try { try {
await navigator.clipboard.writeText(url) await navigator.clipboard.writeText(url);
setCopied(true) setCopied(true);
addNotification('Share link copied to clipboard', 'success') addNotification('Share link copied to clipboard', 'success');
} catch { } catch {
// ignore // ignore
} }
} };
const handleCopy = async () => { const handleCopy = async () => {
if (!shareUrl) return if (!shareUrl) return;
try { try {
await navigator.clipboard.writeText(shareUrl) await navigator.clipboard.writeText(shareUrl);
setCopied(true) setCopied(true);
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000);
addNotification('Share link copied to clipboard', 'success') addNotification('Share link copied to clipboard', 'success');
} catch { } catch {
addNotification('Could not copy share link', 'error') addNotification('Could not copy share link', 'error');
} }
} };
return ( return (
<div className="container"> <div className="container">
<div style={{marginBottom:12}}> <div style={{ marginBottom: 12 }}>
<Link to="/"> Home</Link> <Link to="/"> Home</Link>
</div> </div>
<div className="card"> <div className="card">
<h3>{author?.name}</h3> <h3>{author?.name}</h3>
<div className="small">{author?.bio}</div> <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} />} {post.attachedMarkdown && <MarkdownPreview content={post.attachedMarkdown.content} />}
<div style={{marginTop:8}}> <div style={{ marginTop: 8 }}>
<button className="button" onClick={handleEndorse}>Endorse Post ({post.endorsements})</button> <button className="button" onClick={handleEndorse}>
Endorse Post ({post.endorsements})
</button>
{shareUrl && ( {shareUrl && (
<div className="small" style={{marginTop:8}}> <div className="small" style={{ marginTop: 8 }}>
Share link: <a href={shareUrl} target="_blank" rel="noreferrer">{shareUrl}</a> Share link:{' '}
<button style={{marginLeft:8}} onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</button> <a href={shareUrl} target="_blank" rel="noreferrer">
{shareUrl}
</a>
<button style={{ marginLeft: 8 }} onClick={handleCopy}>
{copied ? 'Copied' : 'Copy'}
</button>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
) );
} };
export default PostDetail export default PostDetail;

View File

@@ -1,50 +1,58 @@
import React from 'react' import React from 'react';
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom';
import useAppStore from '../store/useAppStore' import useAppStore from '../store/useAppStore';
import EndorseButton from '../components/EndorseButton' import EndorseButton from '../components/EndorseButton';
import UserCard from '../components/UserCard' import UserCard from '../components/UserCard';
import { formatTime } from '../utils/fileHelpers' import { formatTime } from '../utils/fileHelpers';
const Profile: React.FC = () => { const Profile: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>();
const user = useAppStore((s) => s.users.find((u) => u.id === id)) const user = useAppStore((s) => s.users.find((u) => u.id === id));
const posts = useAppStore((s) => s.posts.filter((p) => p.authorId === id)) const posts = useAppStore((s) => s.posts.filter((p) => p.authorId === id));
const endorseUser = useAppStore((s) => s.endorseUser) const endorseUser = useAppStore((s) => s.endorseUser);
const currentUserId = useAppStore((s) => s.currentUserId) const currentUserId = useAppStore((s) => s.currentUserId);
const endorsementHistory = useAppStore((s) => s.endorsementHistory) const endorsementHistory = useAppStore((s) => s.endorsementHistory);
const allUsers = useAppStore((s) => s.users) const allUsers = useAppStore((s) => s.users);
const addNotification = useAppStore((s) => s.addNotification) 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) => { const onEmailClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault();
if (!currentUserId) { if (!currentUserId) {
addNotification('Select a current user (top-right) to endorse from.', 'error') addNotification('Select a current user (top-right) to endorse from.', 'error');
return return;
} }
if (currentUserId === user.id) { if (currentUserId === user.id) {
addNotification("You can't endorse yourself.", 'error') addNotification("You can't endorse yourself.", 'error');
return return;
} }
const specialty = user.specialties[0] ?? 'General' const specialty = user.specialties[0] ?? 'General';
endorseUser(user.id, specialty) endorseUser(user.id, specialty);
addNotification(`Endorsed ${user.name} for ${specialty}`, 'success') addNotification(`Endorsed ${user.name} for ${specialty}`, 'success');
} };
return ( return (
<div className="container"> <div className="container">
<div className="card"> <div className="card">
<h2>{user.name}</h2> <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 className="small">{user.bio}</div>
<div style={{marginTop:8}}> <div style={{ marginTop: 8 }}>
{user.specialties.map((s) => ( {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> <span className="tag">{s}</span>
<div className="small">Endorsements: {user.endorsements[s] ?? 0}</div> <div className="small">Endorsements: {user.endorsements[s] ?? 0}</div>
<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>
</div> </div>
))} ))}
@@ -52,17 +60,23 @@ const Profile: React.FC = () => {
</div> </div>
{(() => { {(() => {
const recent = endorsementHistory.filter((e) => e.type === 'user' && e.toUserId === id).slice(0,5) const recent = endorsementHistory
if (recent.length === 0) return null .filter((e) => e.type === 'user' && e.toUserId === id)
.slice(0, 5);
if (recent.length === 0) return null;
return ( return (
<div className="card" style={{marginTop:8}}> <div className="card" style={{ marginTop: 8 }}>
<h4>Recent endorsements</h4> <h4>Recent endorsements</h4>
{recent.map((r) => { {recent.map((r) => {
const byName = allUsers.find((u) => u.id === r.by)?.name ?? 'Someone' 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> return (
<div key={r.id} className="small">
{byName} endorsed {r.specialty} · {formatTime(r.createdAt)} ago
</div>
);
})} })}
</div> </div>
) );
})()} })()}
<h3>Posts</h3> <h3>Posts</h3>
@@ -70,11 +84,11 @@ const Profile: React.FC = () => {
{posts.map((p) => ( {posts.map((p) => (
<div key={p.id} className="card"> <div key={p.id} className="card">
<UserCard user={user} /> <UserCard user={user} />
<div style={{marginTop:8}}>{p.content}</div> <div style={{ marginTop: 8 }}>{p.content}</div>
</div> </div>
))} ))}
</div> </div>
) );
} };
export default Profile export default Profile;

View File

@@ -1,37 +1,61 @@
import create from 'zustand' import create from 'zustand';
import { User } from '../types/User' import { User } from '../types/User';
import { Post } from '../types/Post' import { Post } from '../types/Post';
import { seedUsers, seedPosts } from '../mock/seedData' 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 = { type AppState = {
users: User[] users: User[];
posts: Post[] posts: Post[];
currentUserId: string | null currentUserId: string | null;
selectedPostId: string | null selectedPostId: string | null;
ui: UIState ui: UIState;
endorsementHistory: EndorsementHistoryItem[] endorsementHistory: EndorsementHistoryItem[];
notifications: Notification[] notifications: Notification[];
seedData: () => void seedData: () => void;
createUser: (data: { name: string; email: string; bio: string; specialties: string[] }) => User createUser: (data: { name: string; email: string; bio: string; specialties: string[] }) => User;
setCurrentUser: (id: string | null) => void setCurrentUser: (id: string | null) => void;
createPost: (data: { authorId: string; content: string; attachedMarkdown?: { name: string; content: string } }) => Post createPost: (data: {
endorseUser: (userId: string, specialty: string) => void authorId: string;
endorsePost: (postId: string) => void content: string;
attachPDFToPost: (postId: string, file: File) => void attachedMarkdown?: { name: string; content: string };
attachMarkdownToPost: (postId: string, md: { name: string; content: string }) => void }) => Post;
setSelectedPost: (id: string | null) => void endorseUser: (userId: string, specialty: string) => void;
toggleCreatePost: () => void endorsePost: (postId: string) => void;
addNotification: (message: string, type?: 'success' | 'error' | 'info', duration?: number) => void attachPDFToPost: (postId: string, file: File) => void;
removeNotification: (id: string) => 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) => ({ const useAppStore = create<AppState>((set, get) => ({
users: [], users: [],
@@ -43,13 +67,13 @@ const useAppStore = create<AppState>((set, get) => ({
notifications: [], notifications: [],
seedData: () => { seedData: () => {
const users = seedUsers() const users = seedUsers();
const posts = seedPosts(users) const posts = seedPosts(users);
set(() => ({ users, posts })) set(() => ({ users, posts }));
}, },
createUser: (data) => { createUser: (data) => {
const id = makeId() const id = makeId();
const newUser: User = { const newUser: User = {
id, id,
name: data.name, name: data.name,
@@ -58,15 +82,15 @@ const useAppStore = create<AppState>((set, get) => ({
specialties: data.specialties, specialties: data.specialties,
endorsements: {}, endorsements: {},
createdAt: Date.now(), createdAt: Date.now(),
} };
set((state) => ({ users: [newUser, ...state.users], currentUserId: id })) set((state) => ({ users: [newUser, ...state.users], currentUserId: id }));
return newUser return newUser;
}, },
setCurrentUser: (id) => set(() => ({ currentUserId: id })), setCurrentUser: (id) => set(() => ({ currentUserId: id })),
createPost: (data) => { createPost: (data) => {
const id = makeId() const id = makeId();
const newPost: Post = { const newPost: Post = {
id, id,
authorId: data.authorId, authorId: data.authorId,
@@ -74,59 +98,71 @@ const useAppStore = create<AppState>((set, get) => ({
attachedMarkdown: data.attachedMarkdown, attachedMarkdown: data.attachedMarkdown,
endorsements: 0, endorsements: 0,
createdAt: Date.now(), createdAt: Date.now(),
} };
set((state) => ({ posts: [newPost, ...state.posts] })) set((state) => ({ posts: [newPost, ...state.posts] }));
return newPost return newPost;
}, },
endorseUser: (userId, specialty) => { endorseUser: (userId, specialty) => {
const by = get().currentUserId ?? null const by = get().currentUserId ?? null;
set((state) => ({ set((state) => ({
users: state.users.map((u) => { users: state.users.map((u) => {
if (u.id !== userId) return u if (u.id !== userId) return u;
const current = { ...u.endorsements } const current = { ...u.endorsements };
current[specialty] = (current[specialty] || 0) + 1 current[specialty] = (current[specialty] || 0) + 1;
return { ...u, endorsements: current } 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) => { endorsePost: (postId) => {
const by = get().currentUserId ?? null const by = get().currentUserId ?? null;
set((state) => ({ set((state) => ({
posts: state.posts.map((p) => (p.id === postId ? { ...p, endorsements: p.endorsements + 1 } : p)), posts: state.posts.map((p) =>
endorsementHistory: [{ id: makeId(), type: 'post', by, postId, createdAt: Date.now() }, ...(state.endorsementHistory || [])], p.id === postId ? { ...p, endorsements: p.endorsements + 1 } : p
})) ),
endorsementHistory: [
{ id: makeId(), type: 'post', by, postId, createdAt: Date.now() },
...(state.endorsementHistory || []),
],
}));
}, },
attachPDFToPost: (postId, file) => { attachPDFToPost: (postId, file) => {
const url = URL.createObjectURL(file) const url = URL.createObjectURL(file);
set((state) => ({ 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) => { attachMarkdownToPost: (postId, md) => {
set((state) => ({ set((state) => ({
posts: state.posts.map((p) => (p.id === postId ? { ...p, attachedMarkdown: md } : p)), posts: state.posts.map((p) => (p.id === postId ? { ...p, attachedMarkdown: md } : p)),
})) }));
}, },
setSelectedPost: (id) => set(() => ({ selectedPostId: id })), 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) => { addNotification: (message, type = 'info', duration = 4000) => {
const id = makeId() const id = makeId();
const notif = { id, message, type, createdAt: Date.now() } const notif = { id, message, type, createdAt: Date.now() };
set((state) => ({ notifications: [notif, ...(state.notifications || [])] })) set((state) => ({ notifications: [notif, ...(state.notifications || [])] }));
setTimeout(() => { setTimeout(() => {
set((state) => ({ notifications: (state.notifications || []).filter((n) => n.id !== id) })) set((state) => ({ notifications: (state.notifications || []).filter((n) => n.id !== id) }));
}, duration) }, 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 { export interface AttachedMarkdown {
name: string name: string;
content: string content: string;
} }
export interface Post { export interface Post {
id: string id: string;
authorId: string authorId: string;
content: string content: string;
attachedMarkdown?: AttachedMarkdown attachedMarkdown?: AttachedMarkdown;
endorsements: number endorsements: number;
createdAt: number createdAt: number;
} }

View File

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

View File

@@ -1,32 +1,40 @@
export const formatTime = (ts: number) => { export const formatTime = (ts: number) => {
const diff = Date.now() - ts const diff = Date.now() - ts;
const sec = Math.floor(diff / 1000) const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s` if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60) const min = Math.floor(sec / 60);
if (min < 60) return `${min}m` if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60) const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h` if (hr < 24) return `${hr}h`;
const day = Math.floor(hr / 24) const day = Math.floor(hr / 24);
return `${day}d` return `${day}d`;
} };
export const generateToken = (length = 6) => { export const generateToken = (length = 6) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let out = '' let out = '';
for (let i = 0; i < length; i++) { 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 = () => { export const generateRandomMarkdown = () => {
const titles = ['On the Scalability of Models', 'A Study of Cognitive Load', 'Climate Data Methods', 'Quantum Signaling'] const titles = [
const title = titles[Math.floor(Math.random() * titles.length)] 'On the Scalability of Models',
const intro = 'This is a randomly generated abstract for demo purposes.' 'A Study of Cognitive Load',
const methods = '- Data collection\n- Analysis\n- Validation' 'Climate Data Methods',
const results = 'Preliminary results indicate promising directions.' 'Quantum Signaling',
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 title = titles[Math.floor(Math.random() * titles.length)];
const name = `${title.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'')}.md` const intro = 'This is a randomly generated abstract for demo purposes.';
return { name, content } 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 };
};