Apply UI updates: tags gap, notifications, card click, breadcrumb, move share input
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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)
|
||||
@@ -8,28 +10,32 @@ const CreatePostModal: React.FC = () => {
|
||||
const createPost = useAppStore((s) => s.createPost)
|
||||
|
||||
const [content, setContent] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [attachedMarkdown, setAttachedMarkdown] = useState<{name:string,content:string} | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const onGenerate = () => {
|
||||
const md = generateRandomMarkdown()
|
||||
setAttachedMarkdown(md)
|
||||
}
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!currentUserId) {
|
||||
setError('Select a user first')
|
||||
return
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError('Content required')
|
||||
if (!content.trim() && !attachedMarkdown) {
|
||||
setError('Content or markdown required')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
startTransition(() => {
|
||||
const attachedPDF = file ? { name: file.name, url: URL.createObjectURL(file) } : undefined
|
||||
createPost({ authorId: currentUserId, content: content.trim(), attachedPDF })
|
||||
createPost({ authorId: currentUserId, content: content.trim(), attachedMarkdown: attachedMarkdown ?? undefined })
|
||||
toggle()
|
||||
setContent('')
|
||||
setFile(null)
|
||||
setAttachedMarkdown(null)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,14 +46,8 @@ const CreatePostModal: React.FC = () => {
|
||||
<form onSubmit={onSubmit}>
|
||||
<textarea className="textarea" value={content} onChange={(e) => setContent(e.target.value)} />
|
||||
<div style={{marginTop:8}}>
|
||||
<input type="file" accept="application/pdf" onChange={(e) => {
|
||||
const f = e.target.files && e.target.files[0]
|
||||
if (f && f.type !== 'application/pdf') {
|
||||
setError('Only PDF files allowed')
|
||||
return
|
||||
}
|
||||
setFile(f ?? null)
|
||||
}} />
|
||||
<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}}>
|
||||
|
||||
27
src/components/MarkdownModal.tsx
Normal file
27
src/components/MarkdownModal.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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))
|
||||
|
||||
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'}}>
|
||||
<h3>{post.attachedMarkdown.name}</h3>
|
||||
<button onClick={() => setSelectedPost(null)}>Close</button>
|
||||
</div>
|
||||
<div style={{marginTop:8}}>
|
||||
<MarkdownPreview content={post.attachedMarkdown.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownModal
|
||||
32
src/components/MarkdownPreview.tsx
Normal file
32
src/components/MarkdownPreview.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const renderMarkdown = (md: string) => {
|
||||
if (!md) return ''
|
||||
let html = md
|
||||
// code blocks
|
||||
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>')
|
||||
// bold / italics
|
||||
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>')
|
||||
// lists
|
||||
html = html.replace(/^\s*-\s+(.*)$/gim, '<li>$1</li>')
|
||||
// wrap list items
|
||||
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
|
||||
}
|
||||
|
||||
const MarkdownPreview: React.FC<{ content: string }> = ({ content }) => {
|
||||
const html = useMemo(() => renderMarkdown(content), [content])
|
||||
return <div className="markdown-preview" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
|
||||
export default MarkdownPreview
|
||||
22
src/components/NotificationCenter.tsx
Normal file
22
src/components/NotificationCenter.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
|
||||
if (!notifications || notifications.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="notif-container">
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id} className={`notif ${n.type ?? ''}`}>
|
||||
<div>{n.message}</div>
|
||||
<button onClick={() => removeNotification(n.id)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationCenter
|
||||
@@ -1,46 +1,106 @@
|
||||
import React, { memo } from 'react'
|
||||
import React, { memo, useState } from 'react'
|
||||
import { Post } from '../types/Post'
|
||||
import useAppStore from '../store/useAppStore'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { formatTime } from '../utils/fileHelpers'
|
||||
import PDFPreview from './PDFPreview'
|
||||
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 [shareUrl, setShareUrl] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!author) return null
|
||||
|
||||
const handleEndorse = async (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
if (!currentUserId) {
|
||||
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
|
||||
}
|
||||
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')
|
||||
} 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')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('copy failed', e)
|
||||
addNotification('Could not copy share link', 'error')
|
||||
}
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleCopy = async (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
if (!shareUrl) return
|
||||
try {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<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}`}><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 style={{display:'flex',gap:6}}>
|
||||
<div className="tags-row">
|
||||
{author.specialties.map((sp) => (
|
||||
<span key={sp} className="tag">{sp}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{marginTop:8}}>{post.content}</p>
|
||||
{post.attachedPDF && (
|
||||
{post.attachedMarkdown && (
|
||||
<div style={{marginTop:8}}>
|
||||
<div className="small">{post.attachedPDF.name}</div>
|
||||
<div style={{marginTop:6,display:'flex',gap:8}}>
|
||||
<button className="button" onClick={() => setSelectedPost(post.id)}>Open PDF</button>
|
||||
<PDFPreview url={post.attachedPDF.url} name={post.attachedPDF.name} />
|
||||
<div className="small">{post.attachedMarkdown.name}</div>
|
||||
<div style={{marginTop:6}}>
|
||||
<MarkdownPreview content={post.attachedMarkdown.content} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginTop:8}}>
|
||||
<div>
|
||||
<button className="button" onClick={() => endorsePost(post.id)}>Endorse Post ({post.endorsements})</button>
|
||||
<Link to={`/post/${post.id}`} style={{marginLeft:8}}>View</Link>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
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 onEmailClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
if (!currentUserId) {
|
||||
addNotification('Select a current user (top-right) to endorse from.', 'error')
|
||||
return
|
||||
}
|
||||
if (currentUserId === user.id) {
|
||||
addNotification("You can't endorse yourself.", 'error')
|
||||
return
|
||||
}
|
||||
const specialty = user.specialties[0] ?? 'General'
|
||||
endorseUser(user.id, specialty)
|
||||
addNotification(`Endorsed ${user.name} for ${specialty}`, 'success')
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="small">{user.bio}</div>
|
||||
<div 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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user