Save workspace changes
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
3812
package-lock.json
generated
3812
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
34
src/App.tsx
34
src/App.tsx
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
284
src/index.css
284
src/index.css
@@ -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;
|
||||
}
|
||||
|
||||
12
src/main.tsx
12
src/main.tsx
@@ -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>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user