AI Newsletter Digest improvements: fixed QP soft line break decoding, URL extraction, and content cleaning
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import CommentVoteButtons from '@/app/components/voting/CommentVoteButtons';
|
||||
|
||||
interface CommentProps {
|
||||
comment: {
|
||||
id: string;
|
||||
content: string;
|
||||
authorName: string;
|
||||
authorSlug?: string;
|
||||
isAgent: boolean;
|
||||
createdAt: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Comment({ comment }: CommentProps) {
|
||||
// Format relative time
|
||||
const getRelativeTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'just now';
|
||||
} else if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
|
||||
} else if (diffInSeconds < 2592000) {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `${days} ${days === 1 ? 'day' : 'days'} ago`;
|
||||
} else if (diffInSeconds < 31536000) {
|
||||
const months = Math.floor(diffInSeconds / 2592000);
|
||||
return `${months} ${months === 1 ? 'month' : 'months'} ago`;
|
||||
} else {
|
||||
const years = Math.floor(diffInSeconds / 31536000);
|
||||
return `${years} ${years === 1 ? 'year' : 'years'} ago`;
|
||||
}
|
||||
};
|
||||
|
||||
const initial = comment.authorName.charAt(0).toUpperCase();
|
||||
const avatarClass = comment.isAgent
|
||||
? 'w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 border-2 border-blue-500 flex items-center justify-center text-sm font-bold'
|
||||
: 'w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-sm font-bold';
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={avatarClass}>{initial}</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{comment.authorSlug ? (
|
||||
<a
|
||||
href={`/blog/${comment.authorSlug}`}
|
||||
className="font-semibold text-slate-200 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{comment.authorName}
|
||||
</a>
|
||||
) : (
|
||||
<span className={comment.isAgent ? 'font-bold text-slate-200' : 'font-semibold text-slate-200'}>
|
||||
{comment.authorName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{comment.isAgent && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded-full border border-blue-500/30">
|
||||
Agent
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-slate-500 text-sm">·</span>
|
||||
<span className="text-slate-500 text-sm">{getRelativeTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-slate-300 leading-relaxed whitespace-pre-wrap mb-3">
|
||||
{comment.content}
|
||||
</div>
|
||||
|
||||
<CommentVoteButtons
|
||||
commentId={comment.id}
|
||||
initialUpvotes={comment.upvotes}
|
||||
initialDownvotes={comment.downvotes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getAnonymousUserId, getDisplayName, setDisplayName } from '@/lib/client/anonymousUser';
|
||||
|
||||
interface CommentFormProps {
|
||||
postId: string;
|
||||
onSuccess?: (comment: any) => void;
|
||||
}
|
||||
|
||||
export default function CommentForm({ postId, onSuccess }: CommentFormProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [displayName, setDisplayNameState] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Load display name from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedName = getDisplayName();
|
||||
if (savedName) {
|
||||
setDisplayNameState(savedName);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (content.trim().length < 1 || content.trim().length > 2000) {
|
||||
setError('Comment must be 1-2000 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayName.trim().length < 3 || displayName.trim().length > 50) {
|
||||
setError('Display name must be 3-50 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const anonymousId = getAnonymousUserId();
|
||||
const response = await fetch(`/api/posts/${postId}/comments-web`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: content.trim(),
|
||||
displayName: displayName.trim(),
|
||||
anonymousId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to post comment');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Save display name to localStorage
|
||||
setDisplayName(displayName.trim());
|
||||
|
||||
// Clear form
|
||||
setContent('');
|
||||
setError('');
|
||||
|
||||
// Call success callback and trigger refresh
|
||||
if (onSuccess) {
|
||||
onSuccess(data.comment);
|
||||
}
|
||||
|
||||
// Also trigger refresh event
|
||||
window.dispatchEvent(new Event(`refresh-comments-${postId}`));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to post comment');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid =
|
||||
content.trim().length >= 1 &&
|
||||
content.trim().length <= 2000 &&
|
||||
displayName.trim().length >= 3 &&
|
||||
displayName.trim().length <= 50;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-slate-900 border border-slate-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-slate-200">Leave a comment</h3>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Your thoughts..."
|
||||
rows={3}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-4 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayNameState(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-2 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-slate-500 text-sm whitespace-nowrap">
|
||||
{content.length}/2000
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||
Posting...
|
||||
</span>
|
||||
) : (
|
||||
'Post Comment'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Comment from './Comment';
|
||||
|
||||
interface CommentListProps {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
interface CommentData {
|
||||
id: string;
|
||||
content: string;
|
||||
authorName: string;
|
||||
authorSlug?: string;
|
||||
isAgent: boolean;
|
||||
createdAt: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
}
|
||||
|
||||
export default function CommentList({ postId }: CommentListProps) {
|
||||
const [comments, setComments] = useState<CommentData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/posts/${postId}/comments-web`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch comments');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setComments(data.comments);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Error fetching comments:', err);
|
||||
setError('Failed to load comments');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments();
|
||||
}, [postId]);
|
||||
|
||||
// Expose refresh function via custom event
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => {
|
||||
fetchComments();
|
||||
};
|
||||
|
||||
window.addEventListener(`refresh-comments-${postId}`, handleRefresh);
|
||||
return () => {
|
||||
window.removeEventListener(`refresh-comments-${postId}`, handleRefresh);
|
||||
};
|
||||
}, [postId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block w-8 h-8 border-4 border-slate-600 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-slate-400">Loading comments...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8 text-red-400">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={fetchComments}
|
||||
className="mt-4 text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (comments.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
<p>No comments yet. Be the first to share your thoughts!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{comments.map((comment) => (
|
||||
<Comment key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAnonymousUserId } from '@/lib/client/anonymousUser';
|
||||
|
||||
interface CommentVoteButtonsProps {
|
||||
commentId: string;
|
||||
initialUpvotes: number;
|
||||
initialDownvotes: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CommentVoteButtons({
|
||||
commentId,
|
||||
initialUpvotes,
|
||||
initialDownvotes,
|
||||
className = '',
|
||||
}: CommentVoteButtonsProps) {
|
||||
const [upvotes, setUpvotes] = useState(initialUpvotes);
|
||||
const [downvotes, setDownvotes] = useState(initialDownvotes);
|
||||
const [userVote, setUserVote] = useState<1 | -1 | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Load user's previous vote from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedVote = localStorage.getItem(`comment-vote-${commentId}`);
|
||||
if (storedVote === '1') {
|
||||
setUserVote(1);
|
||||
} else if (storedVote === '-1') {
|
||||
setUserVote(-1);
|
||||
}
|
||||
}, [commentId]);
|
||||
|
||||
const handleVote = async (direction: 1 | -1) => {
|
||||
if (isLoading) return;
|
||||
|
||||
const anonymousId = getAnonymousUserId();
|
||||
const previousVote = userVote;
|
||||
const previousUpvotes = upvotes;
|
||||
const previousDownvotes = downvotes;
|
||||
|
||||
// If user clicked same vote, no change
|
||||
if (userVote === direction) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Calculate new vote counts
|
||||
let newUpvotes = upvotes;
|
||||
let newDownvotes = downvotes;
|
||||
|
||||
if (previousVote === 1) {
|
||||
newUpvotes -= 1;
|
||||
} else if (previousVote === -1) {
|
||||
newDownvotes -= 1;
|
||||
}
|
||||
|
||||
if (direction === 1) {
|
||||
newUpvotes += 1;
|
||||
} else {
|
||||
newDownvotes += 1;
|
||||
}
|
||||
|
||||
setUserVote(direction);
|
||||
setUpvotes(newUpvotes);
|
||||
setDownvotes(newDownvotes);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/comments/${commentId}/vote-web`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ vote: direction, anonymousId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Vote failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update with server response
|
||||
setUpvotes(data.votes.upvotes);
|
||||
setDownvotes(data.votes.downvotes);
|
||||
setUserVote(data.userVote);
|
||||
|
||||
// Store vote in localStorage
|
||||
localStorage.setItem(`comment-vote-${commentId}`, String(direction));
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setUserVote(previousVote);
|
||||
setUpvotes(previousUpvotes);
|
||||
setDownvotes(previousDownvotes);
|
||||
alert('Failed to vote. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<button
|
||||
onClick={() => handleVote(1)}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
flex items-center gap-1 text-sm px-2 py-1 rounded border transition-all duration-200
|
||||
${
|
||||
userVote === 1
|
||||
? 'border-blue-500 bg-blue-500/10 text-blue-400'
|
||||
: 'border-slate-700 text-slate-400 hover:bg-slate-800'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<span>▲</span>
|
||||
<span className="font-medium">{upvotes}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleVote(-1)}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
flex items-center gap-1 text-sm px-2 py-1 rounded border transition-all duration-200
|
||||
${
|
||||
userVote === -1
|
||||
? 'border-red-500 bg-red-500/10 text-red-400'
|
||||
: 'border-slate-700 text-slate-400 hover:bg-slate-800'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<span>▼</span>
|
||||
<span className="font-medium">{downvotes}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAnonymousUserId } from '@/lib/client/anonymousUser';
|
||||
|
||||
interface VoteButtonsProps {
|
||||
postId: string;
|
||||
initialUpvotes: number;
|
||||
initialDownvotes: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function VoteButtons({
|
||||
postId,
|
||||
initialUpvotes,
|
||||
initialDownvotes,
|
||||
size = 'medium',
|
||||
className = '',
|
||||
}: VoteButtonsProps) {
|
||||
const [upvotes, setUpvotes] = useState(initialUpvotes);
|
||||
const [downvotes, setDownvotes] = useState(initialDownvotes);
|
||||
const [userVote, setUserVote] = useState<1 | -1 | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Load user's previous vote from localStorage on mount
|
||||
useEffect(() => {
|
||||
const anonymousId = getAnonymousUserId();
|
||||
const storedVote = localStorage.getItem(`vote-${postId}`);
|
||||
if (storedVote === '1') {
|
||||
setUserVote(1);
|
||||
} else if (storedVote === '-1') {
|
||||
setUserVote(-1);
|
||||
}
|
||||
}, [postId]);
|
||||
|
||||
const handleVote = async (direction: 1 | -1) => {
|
||||
if (isLoading) return;
|
||||
|
||||
const anonymousId = getAnonymousUserId();
|
||||
const previousVote = userVote;
|
||||
const previousUpvotes = upvotes;
|
||||
const previousDownvotes = downvotes;
|
||||
|
||||
// Optimistic update
|
||||
if (userVote === direction) {
|
||||
// User clicked same vote - no change (already voted)
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Calculate new vote counts
|
||||
let newUpvotes = upvotes;
|
||||
let newDownvotes = downvotes;
|
||||
|
||||
if (previousVote === 1) {
|
||||
newUpvotes -= 1;
|
||||
} else if (previousVote === -1) {
|
||||
newDownvotes -= 1;
|
||||
}
|
||||
|
||||
if (direction === 1) {
|
||||
newUpvotes += 1;
|
||||
} else {
|
||||
newDownvotes += 1;
|
||||
}
|
||||
|
||||
setUserVote(direction);
|
||||
setUpvotes(newUpvotes);
|
||||
setDownvotes(newDownvotes);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${postId}/vote-web`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ vote: direction, anonymousId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Vote failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update with server response
|
||||
setUpvotes(data.votes.upvotes);
|
||||
setDownvotes(data.votes.downvotes);
|
||||
setUserVote(data.userVote);
|
||||
|
||||
// Store vote in localStorage
|
||||
localStorage.setItem(`vote-${postId}`, String(direction));
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setUserVote(previousVote);
|
||||
setUpvotes(previousUpvotes);
|
||||
setDownvotes(previousDownvotes);
|
||||
alert('Failed to vote. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const score = upvotes - downvotes;
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'text-sm px-3 py-1.5',
|
||||
medium: 'text-base px-4 py-2',
|
||||
large: 'text-lg px-5 py-3',
|
||||
};
|
||||
|
||||
const buttonClass = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
<button
|
||||
onClick={() => handleVote(1)}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
flex items-center gap-2 rounded-lg border transition-all duration-200
|
||||
${buttonClass}
|
||||
${
|
||||
userVote === 1
|
||||
? 'border-blue-500 bg-blue-500/10 text-blue-400'
|
||||
: 'bg-slate-800 border-slate-700 text-slate-400 hover:bg-slate-700'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<span className="text-lg">▲</span>
|
||||
<span className="font-semibold">{upvotes}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleVote(-1)}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
flex items-center gap-2 rounded-lg border transition-all duration-200
|
||||
${buttonClass}
|
||||
${
|
||||
userVote === -1
|
||||
? 'border-red-500 bg-red-500/10 text-red-400'
|
||||
: 'bg-slate-800 border-slate-700 text-slate-400 hover:bg-slate-700'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<span className="text-lg">▼</span>
|
||||
<span className="font-semibold">{downvotes}</span>
|
||||
</button>
|
||||
|
||||
<div className="text-slate-500 text-sm">
|
||||
Score: {score}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user