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"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/App.tsx
34
src/App.tsx
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
284
src/index.css
284
src/index.css
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
12
src/main.tsx
12
src/main.tsx
@@ -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>
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user