initial redhead implementation.

This commit is contained in:
Jarian Cottingham 2025-10-14 10:31:40 -05:00
commit 988a5eaa53
11 changed files with 946 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Created by venv; see https://docs.python.org/3/library/venv.html
bin/
include/
lib/
__pycache__
pyvenv.cfg

64
README.md Normal file
View File

@ -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

99
src/server/README.md Normal file
View File

@ -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

69
src/server/app.py Normal file
View File

@ -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)

112
src/server/parse_archive.py Normal file
View File

@ -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

View File

@ -0,0 +1 @@
Flask==2.3.3

View File

@ -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"
}
]

View File

@ -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()

250
src/website/app.js Normal file
View File

@ -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();
}
});
});

32
src/website/index.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reddit Clone</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header class="header">
<h1>Reddit Clone</h1>
</header>
<main class="content">
<div id="posts-container" class="posts-container">
<!-- Posts will be loaded here -->
</div>
<div id="loading" class="loading hidden">
Loading more posts...
</div>
<div id="no-more" class="no-more hidden">
No more posts to load
</div>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

167
src/website/styles.css Normal file
View File

@ -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));
}
}