diff --git a/package-lock.json b/package-lock.json index 9b9ae4b5..c08bdc9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "lucide-react": "^0.552.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.5", + "react-router-dom": "^7.11.0", "tailwindcss": "^4.1.16" }, "devDependencies": { @@ -2293,12 +2293,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -2920,9 +2924,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3582,9 +3586,9 @@ } }, "node_modules/react-router": { - "version": "7.9.5", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", - "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", + "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3604,12 +3608,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.5", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz", - "integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz", + "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", "license": "MIT", "dependencies": { - "react-router": "7.9.5" + "react-router": "7.11.0" }, "engines": { "node": ">=20.0.0" diff --git a/package.json b/package.json index 8e30fcd7..210f0bca 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "lucide-react": "^0.552.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.5", + "react-router-dom": "^7.11.0", "tailwindcss": "^4.1.16" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 4b1419ad..a2fc4051 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route } from 'react-router' +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import Home from './pages/Home.tsx' import CreatePostPage from './pages/CreatePostPage.tsx' import Navbar from './components/Navbar.tsx' @@ -8,6 +8,7 @@ import CreateCommunityPage from './pages/CreateCommunityPage.tsx' import {CommunityPage} from './pages/CommunityPage.tsx' import { CommunitiesPage } from './pages/CommunitiesPage.tsx' import MessagesPage from './pages/MessagesPage.tsx' +import NotFound from './components/NotFound.tsx' // Import the 404 page import EventsPage from './pages/EventsPage.tsx' import EventDetailPage from './pages/EventDetailPage.tsx' import CreateEventPage from './pages/CreateEventPage.tsx' @@ -21,6 +22,26 @@ import ProfilePage from './pages/ProfilePage.tsx' function App() { return ( + <> +
+ {/* Wrap everything with Router */} + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Add 404 route at the end - catches all undefined routes */} + } /> + +
+
+
+
@@ -54,4 +75,4 @@ function App() { ) } -export default App +export default App \ No newline at end of file diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx new file mode 100644 index 00000000..0e7b48e0 --- /dev/null +++ b/src/components/CodeBlock.tsx @@ -0,0 +1,65 @@ +// src/components/CodeBlock.tsx +import { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; + +interface CodeBlockProps { + code: string; + language?: string; +} + +export const CodeBlock = ({ code, language = '' }: CodeBlockProps) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy: ', err); + const textArea = document.createElement('textarea'); + textArea.value = code; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( +
+ {/* Header with language and copy button */} +
+ {language && ( + + {language} + + )} + +
+ + {/* Code block */} +
+        {code}
+      
+
+ ); +}; \ No newline at end of file diff --git a/src/components/CommunityDisplay.tsx b/src/components/CommunityDisplay.tsx index c25e57b1..ec79e739 100644 --- a/src/components/CommunityDisplay.tsx +++ b/src/components/CommunityDisplay.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import PostItem from "./PostItem"; +import PostSkeleton from "./PostSkeleton"; // Import the skeleton component import { fetchCommunityPost } from "../utils/communityApi"; interface Props { @@ -27,8 +28,25 @@ const CommunityDisplay = ({ communityId }: Props) => { queryFn: () => fetchCommunityPost(communityId), }); - if (isLoading) - return
Loading posts...
; + // Skeleton loading state + if (isLoading) { + return ( +
+ {/* Skeleton header */} +
+
+
+
+ + {/* Skeleton posts */} +
+ {[...Array(3)].map((_, index) => ( + + ))} +
+
+ ); + } if (error) return ( @@ -52,7 +70,6 @@ const CommunityDisplay = ({ communityId }: Props) => {
{data && data.length > 0 ? ( -
{data.map((post) => ( diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 00000000..0393595f --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,66 @@ +// src/components/CopyButton.tsx +import { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; + +interface CopyButtonProps { + text: string; + className?: string; + size?: 'sm' | 'md' | 'lg'; +} + +export const CopyButton = ({ text, className = '', size = 'md' }: CopyButtonProps) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = text; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const sizeClasses = { + sm: 'px-2 py-1 text-xs', + md: 'px-3 py-1.5 text-sm', + lg: 'px-4 py-2 text-base' + }; + + const iconSize = { + sm: 'w-3 h-3', + md: 'w-4 h-4', + lg: 'w-5 h-5' + }; + + return ( + + ); +}; + +export default CopyButton; \ No newline at end of file diff --git a/src/components/NotFound.tsx b/src/components/NotFound.tsx new file mode 100644 index 00000000..c34362f2 --- /dev/null +++ b/src/components/NotFound.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +const NotFound = () => { + const styles = { + container: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + minHeight: '100vh', + backgroundColor: '#f5f5f5', + padding: '20px', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' + } as React.CSSProperties, + content: { + textAlign: 'center' as const, + backgroundColor: 'white', + padding: '40px', + borderRadius: '12px', + boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)', + maxWidth: '500px', + width: '100%' + } as React.CSSProperties, + title: { + fontSize: '6rem', + fontWeight: 700, + color: '#dc3545', + margin: 0, + lineHeight: 1 + } as React.CSSProperties, + subtitle: { + fontSize: '2rem', + fontWeight: 600, + color: '#333', + margin: '20px 0 10px' + } as React.CSSProperties, + message: { + fontSize: '1.1rem', + color: '#666', + marginBottom: '30px', + lineHeight: '1.5' + } as React.CSSProperties, + button: { + display: 'inline-block', + backgroundColor: '#007bff', + color: 'white', + padding: '12px 30px', + borderRadius: '6px', + textDecoration: 'none', + fontWeight: 500, + fontSize: '1rem', + transition: 'background-color 0.3s ease' + } as React.CSSProperties + }; + + return ( +
+
+

404

+

Page Not Found

+

+ Oops! The page you're looking for doesn't exist or has been moved. +

+ + Return to Feed + +
+
+ ); +}; + +export default NotFound; \ No newline at end of file diff --git a/src/components/PostDetail.tsx b/src/components/PostDetail.tsx index bcdb03a1..94b6e149 100644 --- a/src/components/PostDetail.tsx +++ b/src/components/PostDetail.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { supabase } from '../supabase-client'; -import { MessageCircle, Send, Bookmark, ArrowLeft } from 'lucide-react'; +import { MessageCircle, Send, Bookmark, ArrowLeft, Code, Copy, Check } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import LikeButton from './LikeButton'; import CommentSection from './CommentSection'; +import CopyButton from './CopyButton'; +import { CodeBlock } from './CodeBlock'; interface Post { id: number; @@ -19,6 +21,73 @@ interface PostDetailProps { postId: number; } +// Helper function to parse content and extract code blocks +const parseContentWithCode = (content: string) => { + // Regex to match code blocks with optional language specifier + const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g; + const parts = []; + let lastIndex = 0; + let match; + + // Reset regex lastIndex + codeBlockRegex.lastIndex = 0; + + while ((match = codeBlockRegex.exec(content)) !== null) { + // Add text before code block + if (match.index > lastIndex) { + parts.push({ + type: 'text', + content: content.substring(lastIndex, match.index) + }); + } + + // Add code block + parts.push({ + type: 'code', + language: match[1] || '', + content: match[2].trim() + }); + + lastIndex = match.index + match[0].length; + } + + // Add remaining text after last code block + if (lastIndex < content.length) { + parts.push({ + type: 'text', + content: content.substring(lastIndex) + }); + } + + // If no code blocks were found, return the entire content as text + if (parts.length === 0) { + return [{ type: 'text', content }]; + } + + return parts; +}; + +// Helper function to count code blocks +const countCodeBlocks = (content: string): number => { + const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g; + const matches = content.match(codeBlockRegex); + return matches ? matches.length : 0; +}; + +// Helper function to extract all code snippets for bulk copy +const extractAllCodeSnippets = (content: string): string => { + const codeBlockRegex = /```(?:\w+)?\n([\s\S]*?)```/g; + const snippets: string[] = []; + let match; + + codeBlockRegex.lastIndex = 0; + while ((match = codeBlockRegex.exec(content)) !== null) { + snippets.push(match[1].trim()); + } + + return snippets.join('\n\n' + '-'.repeat(50) + '\n\n'); +}; + const fetchPost = async (postId: number): Promise => { const { data, error } = await supabase .from('Posts') @@ -35,12 +104,34 @@ const fetchPost = async (postId: number): Promise => { const PostDetail = ({ postId }: PostDetailProps) => { const navigate = useNavigate(); const [likeCount, setLikeCount] = useState(0); + const [showAllCode, setShowAllCode] = useState(false); const { data: post, error, isLoading } = useQuery({ queryKey: ["post", postId], queryFn: () => fetchPost(postId) }); + // Parse content for code blocks + const contentParts = useMemo(() => + post ? parseContentWithCode(post.content) : [], + [post] + ); + + const hasCodeBlocks = useMemo(() => + contentParts.some(part => part.type === 'code'), + [contentParts] + ); + + const codeBlocksCount = useMemo(() => + post ? countCodeBlocks(post.content) : 0, + [post] + ); + + const allCodeSnippets = useMemo(() => + post ? extractAllCodeSnippets(post.content) : '', + [post] + ); + if (isLoading) { return (
@@ -86,69 +177,158 @@ const PostDetail = ({ postId }: PostDetailProps) => { {/* Back Button */} {/* Post Card */} -
+
{/* Header: Avatar and Title */} -
+
{post.avatar_url ? ( User avatar ) : ( -
+
)} -
-

{post.title}

-

{formatDate(post.created_at)}

+
+

{post.title}

+

{formatDate(post.created_at)}

+ + {/* Code blocks indicator */} + {hasCodeBlocks && ( +
+ + + {codeBlocksCount} code snippet{codeBlocksCount !== 1 ? 's' : ''} + +
+ )}
{/* Image */} {post.image_url && ( -
+
{post.title}
)} + {/* Copy All Code Button (if has code blocks) */} + {hasCodeBlocks && allCodeSnippets && ( +
+
+
+ +

Code Snippets

+ + {codeBlocksCount} total + +
+
+ + +
+
+
+ )} + + {/* Full Content with Code Blocks */} +
+
+ {contentParts.map((part, index) => { + if (part.type === 'code') { + return ( +
+ + {showAllCode && index < contentParts.length - 1 && ( +
+ )} +
+ ); + } + + // Render text content + if (part.content.trim() === '') return null; + + return ( +
+

+ {part.content} +

+ {showAllCode && index < contentParts.length - 1 && part.type === 'text' && ( +
+ )} +
+ ); + })} +
+
+ {/* Action Buttons */} -
+
+
+
+ + {likeCount} +
+ + +
- - - + {hasCodeBlocks && ( + + )} +
-
{/* Likes Count */} -
-

+

+

{likeCount} {likeCount === 1 ? 'like' : 'likes'}

- {/* Full Content */} -
-
- {post.title} - {post.content} -
-
- {/* Comments Section */} -
+
+
+

Comments

+

Join the discussion about this post

+
diff --git a/src/components/PostItem.tsx b/src/components/PostItem.tsx index 126a6805..6a24fb6a 100644 --- a/src/components/PostItem.tsx +++ b/src/components/PostItem.tsx @@ -1,77 +1,194 @@ import { Link } from 'react-router-dom'; import type { Post } from './PostList'; -import { MessageCircle, Heart } from 'lucide-react'; +import { MessageCircle, Heart, Code } from 'lucide-react'; import LikeButton from './LikeButton'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; +import { CopyButton } from './CopyButton'; +import { CodeBlock } from './CodeBlock'; interface Props { - post: Post; + post: Post; } +// Helper function to detect and extract code blocks from content +const parseContentWithCode = (content: string) => { + // Regex to match code blocks with optional language specifier + const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g; + const parts = []; + let lastIndex = 0; + let match; + + // Reset regex lastIndex + codeBlockRegex.lastIndex = 0; + + while ((match = codeBlockRegex.exec(content)) !== null) { + // Add text before code block + if (match.index > lastIndex) { + parts.push({ + type: 'text', + content: content.substring(lastIndex, match.index) + }); + } + + // Add code block + parts.push({ + type: 'code', + language: match[1] || '', + content: match[2].trim() + }); + + lastIndex = match.index + match[0].length; + } + + // Add remaining text after last code block + if (lastIndex < content.length) { + parts.push({ + type: 'text', + content: content.substring(lastIndex) + }); + } + + // If no code blocks were found, return the entire content as text + if (parts.length === 0) { + return [{ type: 'text', content }]; + } + + return parts; +}; + +// Helper to extract first code snippet for preview +const extractFirstCodeSnippet = (content: string): string | null => { + const codeBlockRegex = /```(?:\w+)?\n([\s\S]*?)```/; + const match = content.match(codeBlockRegex); + return match ? match[1].trim().slice(0, 100) + (match[1].trim().length > 100 ? '...' : '') : null; +}; + const PostItem = ({ post }: Props) => { - const [likeCount, setLikeCount] = useState(0); + const [likeCount, setLikeCount] = useState(0); + + // Parse content to detect code blocks + const contentParts = useMemo(() => parseContentWithCode(post.content), [post.content]); + + // Check if content has code blocks + const hasCodeBlocks = useMemo(() => + contentParts.some(part => part.type === 'code'), + [contentParts] + ); + + // Extract first code snippet for preview + const firstCodeSnippet = useMemo(() => + extractFirstCodeSnippet(post.content), + [post.content] + ); - return ( - -
- {/* Image */} - {post.image_url && ( -
- {post.title} -
-
- )} + return ( + +
+ {/* Image */} + {post.image_url && ( +
+ {post.title} +
+
+ )} - {/* Content */} -
- {/* Header with Avatar */} -
- {post.avatar_url ? ( - User avatar - ) : ( -
- )} -
-

- {post.title} -

-

2h ago

-
-
+ {/* Content */} +
+ {/* Header with Avatar */} +
+ {post.avatar_url ? ( + User avatar + ) : ( +
+ )} +
+

+ {post.title} +

+

2h ago

+
+
- {/* Title and Content Preview */} -
-

- {post.content.length > 150 ? post.content.slice(0, 150) + '...' : post.content} -

-
+ {/* Title and Content Preview */} +
+ {/* Show code snippet preview if available */} + {hasCodeBlocks && firstCodeSnippet && ( +
+
+
+ + Code snippet +
+ +
+
+                  {firstCodeSnippet}
+                
+
+ )} + + {/* Show text content preview */} + {contentParts.filter(part => part.type === 'text').map((part, index) => { + if (part.content.trim() === '') return null; + + const textContent = part.content.length > 150 + ? part.content.slice(0, 150) + '...' + : part.content; + + return ( +

+ {textContent} +

+ ); + })} + + {/* Show code block count badge */} + {hasCodeBlocks && ( +
+ + + {contentParts.filter(part => part.type === 'code').length} code snippet(s) + +
+ )} +
- {/* Stats and Actions */} -
-
-
- - {likeCount} -
-
- - 0 -
-
- -
+ {/* Stats and Actions */} +
+
+
+ + {likeCount} +
+
+ + 0 +
+ {hasCodeBlocks && ( +
+ + Code
+ )}
- - ); + +
+
+
+ + ); } export default PostItem; \ No newline at end of file diff --git a/src/components/PostSkeleton.tsx b/src/components/PostSkeleton.tsx new file mode 100644 index 00000000..29320131 --- /dev/null +++ b/src/components/PostSkeleton.tsx @@ -0,0 +1,44 @@ +// src/components/PostSkeleton.tsx +import React from 'react'; + +const PostSkeleton: React.FC = () => { + return ( +
+ {/* Header with user info */} +
+
+
+
+
+
+
+ + {/* Post title */} +
+ + {/* Post content lines */} +
+
+
+
+
+ + {/* Tags */} +
+
+
+
+ + {/* Action buttons */} +
+
+
+
+
+
+
+
+ ); +}; + +export default PostSkeleton; \ No newline at end of file