Save workspace changes

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

View File

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

View File

@@ -1,11 +1,15 @@
import React from 'react'
import React from 'react';
const EndorseButton: React.FC<{ onClick: () => void; count: number; disabled?: boolean }> = ({ onClick, count, disabled }) => {
const EndorseButton: React.FC<{ onClick: () => void; count: number; disabled?: boolean }> = ({
onClick,
count,
disabled,
}) => {
return (
<button className="button" onClick={onClick} disabled={disabled}>
Endorse ({count})
</button>
)
}
);
};
export default EndorseButton
export default EndorseButton;

View File

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

View File

@@ -1,27 +1,27 @@
import React from 'react'
import useAppStore from '../store/useAppStore'
import MarkdownPreview from './MarkdownPreview'
import React from 'react';
import useAppStore from '../store/useAppStore';
import MarkdownPreview from './MarkdownPreview';
const MarkdownModal: React.FC = () => {
const selectedPostId = useAppStore((s) => s.selectedPostId)
const setSelectedPost = useAppStore((s) => s.setSelectedPost)
const post = useAppStore((s) => s.posts.find((p) => p.id === selectedPostId))
const selectedPostId = useAppStore((s) => s.selectedPostId);
const setSelectedPost = useAppStore((s) => s.setSelectedPost);
const post = useAppStore((s) => s.posts.find((p) => p.id === selectedPostId));
if (!selectedPostId || !post?.attachedMarkdown) return null
if (!selectedPostId || !post?.attachedMarkdown) return null;
return (
<div className="modal-backdrop" onClick={() => setSelectedPost(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3>{post.attachedMarkdown.name}</h3>
<button onClick={() => setSelectedPost(null)}>Close</button>
</div>
<div style={{marginTop:8}}>
<div style={{ marginTop: 8 }}>
<MarkdownPreview content={post.attachedMarkdown.content} />
</div>
</div>
</div>
)
}
);
};
export default MarkdownModal
export default MarkdownModal;

View File

@@ -1,32 +1,35 @@
import React, { useMemo } from 'react'
import React, { useMemo } from 'react';
const renderMarkdown = (md: string) => {
if (!md) return ''
let html = md
if (!md) return '';
let html = md;
// code blocks
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// headings
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>')
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>')
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>')
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
// bold / italics
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
// links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noreferrer">$1</a>'
);
// lists
html = html.replace(/^\s*-\s+(.*)$/gim, '<li>$1</li>')
html = html.replace(/^\s*-\s+(.*)$/gim, '<li>$1</li>');
// wrap list items
html = html.replace(/(<li>[\s\S]*?<\/li>)/gms, '<ul>$1</ul>')
html = html.replace(/(<li>[\s\S]*?<\/li>)/gms, '<ul>$1</ul>');
// paragraphs
html = html.replace(/\n{2,}/g, '</p><p>')
html = `<p>${html}</p>`
return html
}
html = html.replace(/\n{2,}/g, '</p><p>');
html = `<p>${html}</p>`;
return html;
};
const MarkdownPreview: React.FC<{ content: string }> = ({ content }) => {
const html = useMemo(() => renderMarkdown(content), [content])
return <div className="markdown-preview" dangerouslySetInnerHTML={{ __html: html }} />
}
const html = useMemo(() => renderMarkdown(content), [content]);
return <div className="markdown-preview" dangerouslySetInnerHTML={{ __html: html }} />;
};
export default MarkdownPreview
export default MarkdownPreview;

View File

@@ -1,30 +1,39 @@
import React from 'react'
import { Link } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
import React from 'react';
import { Link } from 'react-router-dom';
import useAppStore from '../store/useAppStore';
const Navbar: React.FC = () => {
const users = useAppStore((s) => s.users)
const currentUserId = useAppStore((s) => s.currentUserId)
const setCurrentUser = useAppStore((s) => s.setCurrentUser)
const toggleCreatePost = useAppStore((s) => s.toggleCreatePost)
const users = useAppStore((s) => s.users);
const currentUserId = useAppStore((s) => s.currentUserId);
const setCurrentUser = useAppStore((s) => s.setCurrentUser);
const toggleCreatePost = useAppStore((s) => s.toggleCreatePost);
return (
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Link to="/">Researcher Endorsement</Link>
<Link to="/create-user" style={{marginLeft:12}}>Create User</Link>
<Link to="/create-user" style={{ marginLeft: 12 }}>
Create User
</Link>
</div>
<div style={{display:'flex',alignItems:'center',gap:12}}>
<select value={currentUserId ?? ''} onChange={(e) => setCurrentUser(e.target.value || null)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<select
value={currentUserId ?? ''}
onChange={(e) => setCurrentUser(e.target.value || null)}
>
<option value="">(No user)</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
<button className="button" onClick={() => toggleCreatePost()} disabled={!currentUserId}>Create Post</button>
<button className="button" onClick={() => toggleCreatePost()} disabled={!currentUserId}>
Create Post
</button>
</div>
</div>
)
}
);
};
export default Navbar
export default Navbar;

View File

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

View File

@@ -1,27 +1,27 @@
import React from 'react'
import useAppStore from '../store/useAppStore'
import PDFPreview from './PDFPreview'
import React from 'react';
import useAppStore from '../store/useAppStore';
import PDFPreview from './PDFPreview';
const PDFModal: React.FC = () => {
const selectedPostId = useAppStore((s) => s.selectedPostId)
const setSelectedPost = useAppStore((s) => s.setSelectedPost)
const post = useAppStore((s) => s.posts.find((p) => p.id === selectedPostId))
const selectedPostId = useAppStore((s) => s.selectedPostId);
const setSelectedPost = useAppStore((s) => s.setSelectedPost);
const post = useAppStore((s) => s.posts.find((p) => p.id === selectedPostId));
if (!selectedPostId || !post?.attachedPDF) return null
if (!selectedPostId || !post?.attachedPDF) return null;
return (
<div className="modal-backdrop" onClick={() => setSelectedPost(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3>{post.attachedPDF.name}</h3>
<button onClick={() => setSelectedPost(null)}>Close</button>
</div>
<div style={{marginTop:8}}>
<div style={{ marginTop: 8 }}>
<PDFPreview url={post.attachedPDF.url} name={post.attachedPDF.name} />
</div>
</div>
</div>
)
}
);
};
export default PDFModal
export default PDFModal;

View File

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

View File

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

View File

@@ -1,40 +1,48 @@
import React from 'react'
import { User } from '../types/User'
import { Link } from 'react-router-dom'
import useAppStore from '../store/useAppStore'
import React from 'react';
import { User } from '../types/User';
import { Link } from 'react-router-dom';
import useAppStore from '../store/useAppStore';
const UserCard: React.FC<{ user: User }> = ({ user }) => {
const currentUserId = useAppStore((s) => s.currentUserId)
const endorseUser = useAppStore((s) => s.endorseUser)
const addNotification = useAppStore((s) => s.addNotification)
const currentUserId = useAppStore((s) => s.currentUserId);
const endorseUser = useAppStore((s) => s.endorseUser);
const addNotification = useAppStore((s) => s.addNotification);
const onEmailClick = (e: React.MouseEvent) => {
e.preventDefault()
e.preventDefault();
if (!currentUserId) {
addNotification('Select a current user (top-right) to endorse from.', 'error')
return
addNotification('Select a current user (top-right) to endorse from.', 'error');
return;
}
if (currentUserId === user.id) {
addNotification("You can't endorse yourself.", 'error')
return
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')
}
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;