commit 988a5eaa5327d3acc0103cdf601e3a6600514a85 Author: Jarian Cottingham Date: Tue Oct 14 10:31:40 2025 -0500 initial redhead implementation. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3287a5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Created by venv; see https://docs.python.org/3/library/venv.html + +bin/ +include/ +lib/ + +__pycache__ +pyvenv.cfg diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa44891 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Reddit Clone + +A responsive Reddit-like application with infinite scroll functionality that displays the latest Reddit articles. + +## Features + +- **Infinite Scroll**: Automatically loads more posts as you scroll down +- **Responsive Design**: Works well on both desktop and mobile devices +- **Image Preview**: Each post displays a picture frame with a placeholder image +- **Modern UI**: Clean, attractive interface with hover effects and smooth transitions + +## Project Structure + +``` +. +├── index.html # Main HTML structure +├── styles.css # Responsive styling +└── app.js # JavaScript logic for posts and infinite scroll +``` + +## How It Works + +1. The application loads the first 10 Reddit posts when the page is initially loaded +2. As users scroll down, it automatically loads more posts (10 at a time) +3. Uses Intersection Observer API for efficient infinite scrolling +4. Falls back to manual scroll detection for older browsers +5. Each post displays: + - A title + - An image frame + - A link to the original Reddit post + +## Implementation Details + +- **Responsive Design**: Uses CSS Grid for adaptive layouts that work on mobile and desktop +- **Performance**: Lazy loading of images and efficient scroll handling +- **Mock Data**: Currently uses mock data for demonstration purposes (will be replaced with real API calls) +- **User Experience**: Loading indicators and smooth animations + +## How to Use + +1. Open `index.html` in a web browser +2. Scroll down to see more posts load automatically +3. Click on post titles to view them on Reddit + +## Customization + +To connect to a real Reddit API: +1. Replace the mock data in `app.js` with actual API calls +2. Update the `fetchRedditPosts()` and `fetchMorePosts()` functions to fetch from Reddit's API +3. Adjust styling in `styles.css` to match desired aesthetics + +## Technologies Used + +- HTML5 +- CSS3 (Grid, Flexbox, Media Queries) +- JavaScript ES6+ +- Intersection Observer API for efficient scrolling + +## Responsive Design + +The application is fully responsive and will adapt to: +- Desktop: Grid layout with 2-3 columns based on screen width +- Tablet: Reduced grid columns +- Mobile: Single column layout with appropriate spacing diff --git a/src/server/README.md b/src/server/README.md new file mode 100644 index 0000000..9baecd2 --- /dev/null +++ b/src/server/README.md @@ -0,0 +1,99 @@ +# Reddit Clone API Server + +A Python Flask server that serves data for the Reddit clone application. + +## Endpoints + +- `GET /posts` - Get first 10 posts for initial load +- `GET /posts/more?count=N` - Get next 10 posts, starting from index N +- `GET /posts/total` - Get total number of posts available +- `GET /` - Server status endpoint + +## Setup Instructions + +1. Install Python 3 if you don't have it already +2. Navigate to the server directory: + ```bash + cd /Users/jariancottingham/Projects/redhead/src/server + ``` +3. Create a virtual environment (recommended): + ```bash + python -m venv venv + source venv/bin/activate # On Windows use: venv\Scripts\activate + ``` +4. Install dependencies: + ```bash + pip install -r requirements.txt + ``` +5. Run the server: + ```bash + python app.py + ``` + +6. The server will be available at http://localhost:5000 + +## Data Format + +The server returns JSON data in the same format as used by the frontend: + +```json +[ + { + "id": "1", + "title": "This is a sample Reddit post title", + "url": "https://example.com/sample-post", + "image": "https://picsum.photos/400/300?random=1" + } +] +``` + +## Usage with Frontend + +Update the frontend's `fetchRedditPosts()` and `fetchMorePosts()` functions to call your server endpoints instead of using mock data: + +```javascript +// Replace URLs with your server endpoints +function fetchRedditPosts() { + return fetch('/posts') + .then(response => response.json()) +} + +function fetchMorePosts(currentCount) { + return fetch(`/posts/more?count=${currentCount}`) + .then(response => response.json()) +} +``` + +## Example Data + +The sample data is stored in `sample_posts.json`. You can replace this with your own data, or connect to a real Reddit API. + +To add your own posts: +1. Replace the content of `sample_posts.json` with your data +2. Each post should follow this structure: + ```json + { + "id": "post_id", + "title": "Post title", + "url": "https://example.com/post-url", + "image": "https://example.com/image-url" + } + ``` + +## Running the Server + +After installation, run the server with: + +```bash +python app.py +``` + +The server will start on port 5000 and listen for connections from localhost. + +## Testing Endpoints + +You can test the endpoints directly using curl or a browser: + +- `http://localhost:5000/posts` - Get initial posts +- `http://localhost:5000/posts/more?count=10` - Get more posts starting from index 10 +- `http://localhost:5000/posts/total` - Get total post count diff --git a/src/server/app.py b/src/server/app.py new file mode 100644 index 0000000..afe2a2e --- /dev/null +++ b/src/server/app.py @@ -0,0 +1,69 @@ +from flask import Flask, jsonify, request +import os + +app = Flask(__name__) + +# Get the directory where this script is located +script_dir = os.path.dirname(os.path.abspath(__file__)) + +# Import our archive parser +from parse_archive import ArchiveParser + +# Initialize the archive parser +archive_parser = ArchiveParser() + + +@app.route("/posts", methods=["GET"]) +def get_initial_posts(): + """Get the first 10 posts for initial load""" + try: + # Use archive parser to fetch posts + posts = archive_parser.get_posts(count=10, start_index=0) + return jsonify(posts) + except Exception as e: + return jsonify({"error": "Failed to fetch posts"}), 500 + + +@app.route("/posts/more", methods=["GET"]) +def get_more_posts(): + """Get next 10 posts based on the count parameter""" + try: + # Get the count parameter from query string, default to 10 + count = request.args.get("count", 10, type=int) + + # Use archive parser to fetch posts + posts = archive_parser.get_posts(count=10, start_index=count) + + return jsonify(posts) + except Exception as e: + return jsonify({"error": "Failed to fetch more posts"}), 500 + + +@app.route("/posts/total", methods=["GET"]) +def get_total_posts(): + """Get the total number of posts available""" + try: + # Get total from archive parser + total = archive_parser.get_total_posts() + return jsonify({"total": total}) + except Exception as e: + return jsonify({"error": "Failed to fetch total count"}), 500 + + +@app.route("/") +def home(): + """Home endpoint to verify server is running""" + return jsonify( + { + "message": "Reddit Clone API Server is running", + "endpoints": { + "GET /posts": "Get first 10 posts", + "GET /posts/more?count=N": "Get next 10 posts, starting from index N", + "GET /posts/total": "Get total number of posts", + }, + } + ) + + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=6006) diff --git a/src/server/parse_archive.py b/src/server/parse_archive.py new file mode 100644 index 0000000..218d4e2 --- /dev/null +++ b/src/server/parse_archive.py @@ -0,0 +1,112 @@ +import os +import subprocess +import json +from typing import List, Dict, Any + + +class ArchiveParser: + def __init__(self): + """Initialize the ArchiveParser with archive_dir from environment variable.""" + self.archive_dir = os.environ.get("ARCHIVE_DIR", "/default/archive/path") + + def get_posts(self, count: int, start_index: int = 0) -> List[Dict[str, Any]]: + """ + Get posts from the archive using efficient shell commands. + + For pagination: + - First call: get_posts(count=10, start_index=0) gets first 10 latest posts + - Next call: get_posts(count=10, start_index=N) gets next 10 posts after index N + + Args: + count (int): Number of posts to retrieve + start_index (int): Starting index for retrieving posts + + Returns: + List[Dict[str, Any]]: List of post dictionaries + """ + try: + # Use ls with tail and head commands for efficient pagination + # Get all directories, skip the first start_index, then get count number of entries + cmd = f"ls -1 {self.archive_dir} | tail -n +{start_index + 1} | head -n {count}" + print("Running cmd: " + cmd) + result = subprocess.run( + cmd, shell=True, capture_output=True, text=True, check=True, timeout=30 + ) + + # Parse the output (directory names) + directories = [ + line.strip() for line in result.stdout.split("\n") if line.strip() + ] + + # Extract posts from each directory + posts = [] + for directory in directories: + dir_path = os.path.join(self.archive_dir, directory) + posts.extend(self._extract_posts_from_directory(dir_path)) + + return posts + + except subprocess.CalledProcessError as e: + print(f"Error executing command: {e}") + return [] + except subprocess.TimeoutExpired: + print("Command timed out") + return [] + except Exception as e: + print(f"Error retrieving posts: {e}") + return [] + + def _extract_posts_from_directory( + self, directory_path: str + ) -> List[Dict[str, Any]]: + """Extract posts from a single directory.""" + posts = [] + + # Check if there's an index.json in this directory + index_file_path = os.path.join(directory_path, "index.json") + if os.path.exists(index_file_path): + try: + with open(index_file_path, "r") as f: + data = json.load(f) + # Handle both single post and list of posts in the index.json + if isinstance(data, list): + posts.extend(data) + else: + posts.append(data) + except Exception as e: + print(f"Error reading index.json at {index_file_path}: {e}") + else: + # If no index.json, try to get individual post files (backward compatibility) + try: + for root, dirs, files in os.walk(directory_path): + for file in files: + if file.endswith(".json"): + file_path = os.path.join(root, file) + with open(file_path, "r") as f: + data = json.load(f) + posts.append(data) + except Exception as e: + print(f"Error reading post files: {e}") + + return posts + + def get_total_posts(self) -> int: + """Get the total number of directories (post series) in the archive.""" + try: + # Use ls with wc -l to count directories efficiently + cmd = f"ls -1 {self.archive_dir} | wc -l" + + result = subprocess.run( + cmd, shell=True, capture_output=True, text=True, check=True, timeout=30 + ) + + return int(result.stdout.strip()) + except subprocess.CalledProcessError as e: + print(f"Error executing count command: {e}") + return 0 + except subprocess.TimeoutExpired: + print("Count command timed out") + return 0 + except Exception as e: + print(f"Error getting total posts: {e}") + return 0 diff --git a/src/server/requirements.txt b/src/server/requirements.txt new file mode 100644 index 0000000..597ce19 --- /dev/null +++ b/src/server/requirements.txt @@ -0,0 +1 @@ +Flask==2.3.3 diff --git a/src/server/sample_posts.json b/src/server/sample_posts.json new file mode 100644 index 0000000..7932f9f --- /dev/null +++ b/src/server/sample_posts.json @@ -0,0 +1,122 @@ +[ + { + "id": "1", + "title": "This is a sample Reddit post title", + "url": "https://example.com/sample-post", + "image": "https://picsum.photos/400/300?random=1" + }, + { + "id": "2", + "title": "Another interesting Reddit post about technology", + "url": "https://example.com/tech-post", + "image": "https://picsum.photos/400/300?random=2" + }, + { + "id": "3", + "title": "Amazing landscape photography from nature", + "url": "https://example.com/nature-photography", + "image": "https://picsum.photos/400/300?random=3" + }, + { + "id": "4", + "title": "How to build a React application from scratch", + "url": "https://example.com/react-guide", + "image": "https://picsum.photos/400/300?random=4" + }, + { + "id": "5", + "title": "The future of artificial intelligence in 2023", + "url": "https://example.com/ai-future", + "image": "https://picsum.photos/400/300?random=5" + }, + { + "id": "6", + "title": "Delicious recipes for vegetarian meals", + "url": "https://example.com/vegetarian-recipes", + "image": "https://picsum.photos/400/300?random=6" + }, + { + "id": "7", + "title": "Top programming languages to learn in 2023", + "url": "https://example.com/programming-languages", + "image": "https://picsum.photos/400/300?random=7" + }, + { + "id": "8", + "title": "Space exploration updates from NASA", + "url": "https://example.com/space-exploration", + "image": "https://picsum.photos/400/300?random=8" + }, + { + "id": "9", + "title": "Tips for improving your sleep quality", + "url": "https://example.com/sleep-tips", + "image": "https://picsum.photos/400/300?random=9" + }, + { + "id": "10", + "title": "Latest trends in web development", + "url": "https://example.com/web-development-trends", + "image": "https://picsum.photos/400/300?random=10" + }, + { + "id": "11", + "title": "Understanding blockchain technology and its applications", + "url": "https://example.com/blockchain-guide", + "image": "https://picsum.photos/400/300?random=11" + }, + { + "id": "12", + "title": "The health benefits of regular exercise", + "url": "https://example.com/health-exercise", + "image": "https://picsum.photos/400/300?random=12" + }, + { + "id": "13", + "title": "Exploring the world's most ancient civilizations", + "url": "https://example.com/ancient-civilizations", + "image": "https://picsum.photos/400/300?random=13" + }, + { + "id": "14", + "title": "Sustainable living practices for modern homes", + "url": "https://example.com/sustainable-living", + "image": "https://picsum.photos/400/300?random=14" + }, + { + "id": "15", + "title": "The art of coffee brewing: A beginner's guide", + "url": "https://example.com/coffee-brewing", + "image": "https://picsum.photos/400/300?random=15" + }, + { + "id": "16", + "title": "Mental health awareness and self-care tips", + "url": "https://example.com/mental-health", + "image": "https://picsum.photos/400/300?random=16" + }, + { + "id": "17", + "title": "The rise of renewable energy sources worldwide", + "url": "https://example.com/renewable-energy", + "image": "https://picsum.photos/400/300?random=17" + }, + { + "id": "18", + "title": "Travel photography techniques for stunning landscapes", + "url": "https://example.com/travel-photography", + "image": "https://picsum.photos/400/300?random=18" + }, + { + "id": "19", + "title": "Building meaningful relationships in the digital age", + "url": "https://example.com/digital-relationships", + "image": "https://picsum.photos/400/300?random=19" + }, + { + "id": "20", + "title": "The impact of social media on modern society", + "url": "https://example.com/social-media-impact", + "image": "https://picsum.photos/400/300?random=20" + } +] diff --git a/src/server/test_parse_archive.py b/src/server/test_parse_archive.py new file mode 100644 index 0000000..8e86796 --- /dev/null +++ b/src/server/test_parse_archive.py @@ -0,0 +1,22 @@ +import os +import tempfile +import json +from parse_archive import ArchiveParser + + +def test1(): + parser = ArchiveParser() + posts = parser.get_posts(10) + for p in posts: + print(p) + + +def test2(): + parser = ArchiveParser() + posts = parser.get_posts(10, 10) + for p in posts: + print(p) + + +if __name__ == "__main__": + test1() diff --git a/src/website/app.js b/src/website/app.js new file mode 100644 index 0000000..4db8724 --- /dev/null +++ b/src/website/app.js @@ -0,0 +1,250 @@ +// Mock Reddit data for demonstration purposes - in real app this would come from an API +const mockRedditPosts = [ + { + id: "1", + title: "This is a sample Reddit post title", + url: "https://example.com/sample-post", + image: "https://picsum.photos/400/300?random=1", + }, + { + id: "2", + title: "Another interesting Reddit post about technology", + url: "https://example.com/tech-post", + image: "https://picsum.photos/400/300?random=2", + }, + { + id: "3", + title: "Amazing landscape photography from nature", + url: "https://example.com/nature-photography", + image: "https://picsum.photos/400/300?random=3", + }, + { + id: "4", + title: "How to build a React application from scratch", + url: "https://example.com/react-guide", + image: "https://picsum.photos/400/300?random=4", + }, + { + id: "5", + title: "The future of artificial intelligence in 2023", + url: "https://example.com/ai-future", + image: "https://picsum.photos/400/300?random=5", + }, + { + id: "6", + title: "Delicious recipes for vegetarian meals", + url: "https://example.com/vegetarian-recipes", + image: "https://picsum.photos/400/300?random=6", + }, + { + id: "7", + title: "Top programming languages to learn in 2023", + url: "https://example.com/programming-languages", + image: "https://picsum.photos/400/300?random=7", + }, + { + id: "8", + title: "Space exploration updates from NASA", + url: "https://example.com/space-exploration", + image: "https://picsum.photos/400/300?random=8", + }, + { + id: "9", + title: "Tips for improving your sleep quality", + url: "https://example.com/sleep-tips", + image: "https://picsum.photos/400/300?random=9", + }, + { + id: "10", + title: "Latest trends in web development", + url: "https://example.com/web-development-trends", + image: "https://picsum.photos/400/300?random=10", + }, + { + id: "11", + title: "Health benefits of daily exercise", + url: "https://example.com/health-exercise", + image: "https://picsum.photos/400/300?random=11", + }, + { + id: "12", + title: "Understanding blockchain technology explained", + url: "https://example.com/blockchain-explained", + image: "https://picsum.photos/400/300?random=12", + }, + { + id: "13", + title: "Best travel destinations for summer 2023", + url: "https://example.com/travel-destinations", + image: "https://picsum.photos/400/300?random=13", + }, + { + id: "14", + title: "Cooking tips from professional chefs", + url: "https://example.com/chef-tips", + image: "https://picsum.photos/400/300?random=14", + }, + { + id: "15", + title: "The history of the internet and its evolution", + url: "https://example.com/internet-history", + image: "https://picsum.photos/400/300?random=15", + }, +]; + +// For the real implementation, this would be replaced with an actual API call +function fetchRedditPosts() { + return new Promise((resolve) => { + // Simulate network delay + setTimeout(() => { + // Return first 10 posts for initial load + resolve(mockRedditPosts.slice(0, 10)); + }, 500); + }); +} + +// For the real implementation, this would be replaced with an actual API call for more posts +function fetchMorePosts(currentCount) { + return new Promise((resolve) => { + // Simulate network delay + setTimeout(() => { + // Return next 10 posts + const start = currentCount; + const end = start + 10; + resolve(mockRedditPosts.slice(start, end)); + }, 500); + }); +} + +let isLoading = false; +let currentPostCount = 10; + +function createPostElement(post) { + const postElement = document.createElement("div"); + postElement.className = "post"; + + // Create the image container + const imageContainer = document.createElement("div"); + imageContainer.className = "post-image-container"; + + const image = document.createElement("img"); + image.src = post.image; + image.alt = post.title; + image.className = "post-image"; + image.loading = "lazy"; + + imageContainer.appendChild(image); + + // Create the content container + const content = document.createElement("div"); + content.className = "post-content"; + + const title = document.createElement("a"); + title.href = post.url; + title.target = "_blank"; + title.rel = "noopener noreferrer"; + title.className = "post-title"; + title.textContent = post.title; + + content.appendChild(title); + + // Assemble the complete post + postElement.appendChild(imageContainer); + postElement.appendChild(content); + + return postElement; +} + +function renderPosts(posts) { + const container = document.getElementById("posts-container"); + + posts.forEach((post) => { + const postElement = createPostElement(post); + container.appendChild(postElement); + }); +} + +async function loadInitialPosts() { + try { + const posts = await fetchRedditPosts(); + renderPosts(posts); + currentPostCount = posts.length; + } catch (error) { + console.error("Failed to load initial posts:", error); + } +} + +async function loadMorePosts() { + if (isLoading || currentPostCount >= mockRedditPosts.length) { + return; + } + + isLoading = true; + document.getElementById("loading").classList.remove("hidden"); + + try { + const morePosts = await fetchMorePosts(currentPostCount); + + if (morePosts.length > 0) { + renderPosts(morePosts); + currentPostCount += morePosts.length; + } else { + // No more posts to load + document.getElementById("no-more").classList.remove("hidden"); + document.getElementById("loading").classList.add("hidden"); + } + } catch (error) { + console.error("Failed to load more posts:", error); + } finally { + isLoading = false; + document.getElementById("loading").classList.add("hidden"); + } +} + +// Infinite scroll implementation +function setupInfiniteScroll() { + const container = document.getElementById("posts-container"); + + // Use Intersection Observer API for efficient infinite scrolling + const observer = new IntersectionObserver( + (entries) => { + if ( + entries[0].isIntersecting && + !isLoading && + currentPostCount < mockRedditPosts.length + ) { + loadMorePosts(); + } + }, + { + root: null, + rootMargin: "20px", + threshold: 0.1, + }, + ); + + // Create a sentinel element to observe + const sentinel = document.createElement("div"); + sentinel.id = "sentinel"; + container.appendChild(sentinel); + + observer.observe(sentinel); +} + +// Initialize the app when the DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + loadInitialPosts(); + setupInfiniteScroll(); + + // Also handle scroll manually for browsers that don't support Intersection Observer + window.addEventListener("scroll", () => { + if ( + window.innerHeight + window.scrollY >= + document.body.offsetHeight - 1000 && + !isLoading && + currentPostCount < mockRedditPosts.length + ) { + loadMorePosts(); + } + }); +}); diff --git a/src/website/index.html b/src/website/index.html new file mode 100644 index 0000000..655502b --- /dev/null +++ b/src/website/index.html @@ -0,0 +1,32 @@ + + + + + + Reddit Clone + + + +
+
+

Reddit Clone

+
+ +
+
+ +
+ + + + +
+
+ + + + diff --git a/src/website/styles.css b/src/website/styles.css new file mode 100644 index 0000000..15ab570 --- /dev/null +++ b/src/website/styles.css @@ -0,0 +1,167 @@ +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + line-height: 1.6; + color: #333; + background-color: #f0f0f0; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 15px; +} + +.header { + background-color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 15px 0; + position: sticky; + top: 0; + z-index: 100; +} + +.header h1 { + text-align: center; + font-size: 1.8rem; + color: #ff4500; +} + +.content { + padding: 20px 0; +} + +.posts-container { + display: grid; + gap: 20px; +} + +.post { + background-color: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; + transition: + transform 0.2s, + box-shadow 0.2s; +} + +.post:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.post-content { + padding: 20px; +} + +.post-title { + font-size: 1.3rem; + margin-bottom: 15px; + color: #000; + text-decoration: none; + display: block; +} + +.post-title:hover { + color: #ff4500; +} + +.post-image-container { + width: 100%; + height: 250px; + overflow: hidden; + background-color: #f8f8f8; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; +} + +.post-image { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s; +} + +.post:hover .post-image { + transform: scale(1.05); +} + +.image-placeholder { + color: #999; + font-size: 1.2rem; +} + +.loading, +.no-more { + text-align: center; + padding: 20px; + font-size: 1.1rem; + color: #666; +} + +.hidden { + display: none; +} + +/* Responsive design */ +@media (max-width: 768px) { + .container { + padding: 0 10px; + } + + .header h1 { + font-size: 1.5rem; + } + + .post-content { + padding: 15px; + } + + .post-title { + font-size: 1.1rem; + } + + .post-image-container { + height: 200px; + } +} + +@media (max-width: 480px) { + .post-image-container { + height: 150px; + } + + .header h1 { + font-size: 1.3rem; + } + + .post-title { + font-size: 1rem; + } +} + +/* Grid layout for desktop */ +@media (min-width: 769px) { + .posts-container { + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + } +} + +/* Tablet view */ +@media (min-width: 481px) and (max-width: 768px) { + .posts-container { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } +}