Compare commits

..

10 Commits

Author SHA1 Message Date
9664e945d5 chore: remove local artifacts
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions
2026-08-21 17:15:18 +00:00
jarianc
df9e37f7f1 Merge pull request 'CI: remove --no-cache for docker layer caching' (#1) from ci-fix-nocache into main
Reviewed-on: https://git.home.ms/jarianc/redhead/pulls/1
2026-07-04 22:24:08 -05:00
Jarian
d2ad449eb9 CI: remove --no-cache for docker layer caching 2026-07-05 03:14:30 +00:00
Jarian
65d9771a13 CI: add generalized workflow 2026-07-05 02:46:38 +00:00
Jarian Cottingham
7ada2278c6 Implement backend pre-caching optimization for faster load times with 300-article prefetching 2026-02-02 09:43:03 -06:00
Jarian Cottingham
432c162642 Clean up website design and add search functionality 2026-02-02 09:30:34 -06:00
Jarian Cottingham
6786f982f2 update git ignore 2026-02-02 09:08:46 -06:00
Jarian Cottingham
70ce394302 cleanup 2026-02-02 09:07:38 -06:00
Jarian Cottingham
eed0dd9ced Simplified project structure and fixed Docker configuration to properly point to archive directory 2026-02-02 09:05:56 -06:00
Jarian Cottingham
57ac07730e Simplified Docker setup with minimal configuration 2026-02-02 08:46:30 -06:00
18 changed files with 667 additions and 403 deletions

BIN
.DS_Store vendored

Binary file not shown.

143
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,143 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
env:
GITEA_URL: https://git.home.ms
jobs:
lint:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run ruff (Python lint)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
pip3 install ruff
ruff check .
else
echo "No Python project detected, skipping ruff"
fi
- name: Run npm lint (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm run lint --if-present || true
else
echo "No Node.js project detected, skipping npm lint"
fi
test:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run pytest (Python)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
python3 -m pip install --upgrade pip
pip3 install -e ".[dev]" 2>/dev/null || pip3 install -e . 2>/dev/null || true
pip3 install pytest
pytest tests/ -v --tb=short 2>/dev/null || true
else
echo "No Python project detected, skipping pytest"
fi
- name: Run npm test (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm run test --if-present || true
else
echo "No Node.js project detected, skipping npm test"
fi
- name: Run Go tests
if: always()
run: |
if [[ -f go.mod ]]; then
go test ./...
else
echo "No Go project detected, skipping go test"
fi
docker-build:
runs-on: ubuntu-latest
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Build Docker image
if: always()
run: |
if [[ -f Dockerfile ]]; then
docker build -t $GITHUB_REPOSITORY:test .
else
echo "No Dockerfile found, skipping docker build"
fi
security:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run bandit (Python SAST)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
pip3 install bandit
bandit -r . --severity-level high --confidence-level high --exclude tests/,test_*
else
echo "No Python project detected, skipping bandit"
fi
- name: Run npm audit (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm audit --audit-level=high 2>/dev/null || echo "npm audit: vulnerabilities found (non-blocking)"
else
echo "No Node.js project detected, skipping npm audit"
fi
build-result:
needs: [lint, test, docker-build, security]
runs-on: ubuntu-latest
container:
image: gitea-job-image
if: always()
steps:
- name: Summary
run: echo "All CI checks completed"

4
.gitignore vendored
View File

@ -67,3 +67,7 @@ cache/
# System files
.DS_Store
._*
venv/
bin/
share/

View File

@ -1,64 +1,22 @@
# Multi-stage Dockerfile for Red Head Python Backend
# Build stage
FROM python:3.9-slim as builder
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user and group for building
RUN groupadd --gid 1001 builder && \
useradd --uid 1001 --gid builder --shell /bin/bash --create-home builder
# Switch to builder user for dependency installation
USER builder
WORKDIR /home/builder
# Copy requirements and install Python dependencies in a virtual environment
COPY src/server/requirements.txt .
RUN python -m venv /home/builder/venv && \
/home/builder/venv/bin/pip install --no-cache-dir -r requirements.txt
# Production stage
# Simple Dockerfile for Red Head Python Backend
FROM python:3.9-slim
# Create a non-root user and group
RUN groupadd --gid 1001 appuser && \
useradd --uid 1001 --gid appuser --shell /bin/bash --create-home appuser
# Set working directory
WORKDIR /app
# Copy Python dependencies from builder stage (virtual environment)
COPY --from=builder /home/builder/venv /app/venv
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY --chown=appuser:appuser src/server/ .
# Copy website files
COPY --chown=appuser:appuser src/website/ website/
# Copy application code and all files
COPY . .
# Create directory for cache if it doesn't exist
RUN mkdir -p website/cache && \
chown -R appuser:appuser website/cache
RUN mkdir -p cache
# Expose port
EXPOSE 6006
# Set environment variables
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
ENV USER=appuser
ENV PATH=/app/venv/bin:$PATH
# Switch to non-root user
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:6006/api || exit 1

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,6 +1,8 @@
// Global variables
let isLoading = false;
let currentPostCount = 0;
let allPosts = []; // Store all posts for search functionality
let filteredPosts = []; // Store currently displayed posts
const POSTS_CACHE_KEY = "cached_posts_100";
const CACHE_EXPIRY_TIME = 5 * 60 * 1000; // 5 minutes
@ -116,13 +118,36 @@ function createPostElement(post) {
return postElement;
}
// Function to update search results counter
function updateSearchResultsCount() {
const searchResultsElement = document.getElementById("search-results");
const searchInput = document.getElementById("search-input");
if (searchResultsElement && searchInput) {
const searchTerm = searchInput.value.trim().toLowerCase();
const count = searchTerm ? filteredPosts.length : allPosts.length;
if (searchResultsElement) {
searchResultsElement.textContent = searchTerm
? `${filteredPosts.length} post${filteredPosts.length !== 1 ? 's' : ''} found`
: `${allPosts.length} post${allPosts.length !== 1 ? 's' : ''}`;
}
}
}
function renderPosts(posts) {
const container = document.getElementById("posts-container");
// Clear container
container.innerHTML = "";
posts.forEach((post) => {
const postElement = createPostElement(post);
container.appendChild(postElement);
});
// Update search results counter
updateSearchResultsCount();
}
// Cache management functions
@ -166,6 +191,52 @@ function clearCachedPosts() {
}
}
// Search functionality
function performSearch() {
const searchInput = document.getElementById("search-input");
const searchTerm = searchInput.value.trim().toLowerCase();
if (searchTerm === "") {
// Show all posts if search is empty
filteredPosts = [...allPosts];
renderPosts(filteredPosts);
return;
}
// Filter posts by title (case-insensitive partial match)
filteredPosts = allPosts.filter(post =>
post.title &&
post.title.toLowerCase().includes(searchTerm)
);
renderPosts(filteredPosts);
}
// Initialize search functionality
function initializeSearch() {
const searchInput = document.getElementById("search-input");
if (searchInput) {
// Add debouncing to search for better performance
let searchTimeout;
searchInput.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(performSearch, 300); // 300ms delay
});
// Add search results counter
const searchResults = document.createElement("div");
searchResults.id = "search-results";
searchResults.className = "search-results";
searchResults.textContent = "Loading posts...";
// Insert search results counter after the header
const header = document.querySelector(".header");
if (header) {
header.parentNode.insertBefore(searchResults, header.nextSibling);
}
}
}
// Extract real title from base_url
function extractRealTitleFromUrl(baseUrl) {
if (!baseUrl || typeof baseUrl !== "string") {
@ -236,7 +307,9 @@ async function loadInitialPosts() {
if (cachedPosts && cachedPosts.length > 0) {
console.log("Loading posts from cache");
renderPosts(cachedPosts);
allPosts = cachedPosts;
filteredPosts = [...allPosts];
renderPosts(filteredPosts);
currentPostCount = cachedPosts.length;
// Check if we have more posts or not
@ -253,7 +326,9 @@ async function loadInitialPosts() {
try {
console.log("Fetching posts from API");
const posts = await fetchRedditPosts();
renderPosts(posts);
allPosts = posts;
filteredPosts = [...allPosts];
renderPosts(filteredPosts);
currentPostCount = posts.length;
// Cache the posts
@ -284,7 +359,11 @@ async function loadMorePosts() {
const morePosts = await fetchMorePosts(currentPostCount);
if (morePosts.length > 0) {
renderPosts(morePosts);
// Add new posts to allPosts and filteredPosts
allPosts = [...allPosts, ...morePosts];
filteredPosts = [...filteredPosts, ...morePosts];
renderPosts(filteredPosts);
currentPostCount += morePosts.length;
} else {
// No more posts to load
@ -329,6 +408,7 @@ function setupInfiniteScroll() {
document.addEventListener("DOMContentLoaded", () => {
loadInitialPosts();
setupInfiniteScroll();
initializeSearch();
// Add event listener to dark mode toggle button
const darkModeToggle = document.getElementById("dark-mode-toggle");

View File

@ -1,10 +1,12 @@
from flask import Flask, jsonify, request, send_from_directory
from flask_cors import CORS
from flask import Flask, jsonify, request, send_from_directory, send_file
import os
import sys
import logging
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Import our archive parser
from parse_archive import ArchiveParser
@ -12,51 +14,18 @@ from parse_archive import ArchiveParser
# Initialize the archive parser
archive_parser = ArchiveParser()
# Validate archive directory at startup
def validate_archive_directory():
"""Validate that the archive directory exists and has content"""
archive_dir = os.environ.get('ARCHIVE_DIR', '/app/archive')
print(f"Checking archive directory: {archive_dir}")
if not os.path.exists(archive_dir):
print(f"ERROR: Archive directory does not exist: {archive_dir}")
return False
try:
# Try to list directories in archive
dirs = os.listdir(archive_dir)
if not dirs:
print(f"WARNING: Archive directory is empty: {archive_dir}")
else:
print(f"INFO: Archive directory contains {len(dirs)} items")
return True
except Exception as e:
print(f"ERROR: Cannot access archive directory {archive_dir}: {e}")
return False
# Validate at startup
if not validate_archive_directory():
print("FATAL: Archive directory validation failed. Application cannot start.")
sys.exit(1)
# Serve static files from the website directory
# The website directory is at the same level as the src directory
# Serve static files from the root directory
@app.route("/<path:filename>")
def serve_static(filename):
# In Docker, the structure is: /app/src/server/ and /app/website/
# So we need to go up from src/server to /app/ and then to /app/website
website_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "website")
return send_from_directory(website_dir, filename)
try:
return send_from_directory(".", filename)
except FileNotFoundError:
return jsonify({"error": "File not found"}), 404
# Serve the main index.html page
@app.route("/")
def serve_index():
# In Docker, the structure is: /app/src/server/ and /app/website/
website_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "website")
return send_from_directory(website_dir, "index.html")
return send_file("index.html")
@app.route("/posts", methods=["GET"])
def get_initial_posts():
@ -73,7 +42,6 @@ def get_initial_posts():
print(f"Error: {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"""
@ -89,10 +57,9 @@ def get_more_posts():
filters_posts += [p]
return jsonify(filters_posts)
except Exception as e:
print(f"Error: {e}")
logger.error(f"Error fetching more posts: {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"""
@ -101,10 +68,9 @@ def get_total_posts():
total = archive_parser.get_total_posts()
return jsonify({"total": total})
except Exception as e:
print(f"Error: {e}")
logger.error(f"Error fetching total posts: {e}")
return jsonify({"error": "Failed to fetch total count"}), 500
@app.route("/api")
def home():
"""Home endpoint to verify server is running"""
@ -116,9 +82,9 @@ def home():
"GET /posts/more?count=N": "Get next 10 posts, starting from index N",
"GET /posts/total": "Get total number of posts",
},
"status": "healthy"
}
)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=6006)

View File

@ -10,10 +10,7 @@ services:
- ARCHIVE_DIR=/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/
- FLASK_ENV=production
volumes:
- ./src/server:/app/src/server
- ./src/website:/app/website
- ./website/cache:/app/website/cache
- /app/src/server/__pycache__
- .:/app
# Mount the actual archive directory
- /mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/:/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/
working_dir: /app

View File

@ -0,0 +1,5 @@
{
"load_extensions": {
"jupyter-js-widgets/extension": true
}
}

View File

@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

View File

@ -10,6 +10,15 @@
<div class="container">
<header class="header">
<h1>Red Head</h1>
<div class="search-container">
<span class="search-icon">🔍</span>
<input
type="text"
id="search-input"
placeholder="Search posts by title..."
autocomplete="off"
/>
</div>
<button
id="dark-mode-toggle"
class="dark-mode-toggle"

View File

@ -5,6 +5,8 @@ import urllib.parse
import uuid
from io import BytesIO
from typing import Any, Dict, List
from concurrent.futures import ThreadPoolExecutor
import threading
import requests
from PIL import Image
@ -14,6 +16,10 @@ 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")
self.cache = {}
self.cache_lock = threading.Lock()
self.pre_cache_size = 300 # Pre-cache 300 articles ahead
self.cache_executor = ThreadPoolExecutor(max_workers=2) # For background caching
def get_posts(self, count: int, start_index: int = 0) -> List[Dict[str, Any]]:
"""
@ -31,6 +37,13 @@ class ArchiveParser:
List[Dict[str, Any]]: List of post dictionaries
"""
try:
# Check if we have cached posts for this range
cache_key = f"posts_{start_index}_{count}"
with self.cache_lock:
if cache_key in self.cache:
print(f"Cache hit for {cache_key}")
return self.cache[cache_key]
# 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 -{start_index + count + 1} | head -{count}"
@ -52,6 +65,15 @@ class ArchiveParser:
# Convert extracted posts to final format with proper IDs
formatted_posts = [self._create_post(post) for post in posts]
# Cache the results
with self.cache_lock:
self.cache[cache_key] = formatted_posts
# Pre-cache the next batch in background
if start_index + count < self.get_total_posts():
self._pre_cache_batch(start_index + count)
return formatted_posts
except subprocess.CalledProcessError as e:
@ -119,6 +141,24 @@ class ArchiveParser:
print(f"Error getting total posts: {e}")
return 0
def _pre_cache_batch(self, start_index: int):
"""Pre-cache the next batch of posts in the background."""
def cache_task():
try:
# Pre-cache next 300 posts
next_posts = self.get_posts(self.pre_cache_size, start_index)
print(f"Pre-cached {len(next_posts)} posts starting from index {start_index}")
except Exception as e:
print(f"Error in pre-caching: {e}")
# Submit to background thread
self.cache_executor.submit(cache_task)
def clear_cache(self):
"""Clear the cache."""
with self.cache_lock:
self.cache.clear()
def _extract_real_title_from_url(self, base_url: str) -> str:
"""Extract the real title from base_url when title is 'Reddit - Prove your humanity'."""
if not base_url or not isinstance(base_url, str):

5
pyvenv.cfg Normal file
View File

@ -0,0 +1,5 @@
home = /opt/homebrew/opt/python@3.13/bin
include-system-site-packages = false
version = 3.13.7
executable = /opt/homebrew/Cellar/python@3.13/3.13.7/Frameworks/Python.framework/Versions/3.13/bin/python3.13
command = /opt/homebrew/opt/python@3.13/bin/python3.13 -m venv /Users/jariancottingham/Projects/redhead

View File

@ -1,99 +0,0 @@
# red head API Server
A Python Flask server that serves data for the red head 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

View File

@ -1,122 +0,0 @@
[
{
"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

@ -1,22 +0,0 @@
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()

View File

@ -15,6 +15,7 @@ body {
transition:
background-color 0.3s,
color 0.3s;
min-height: 100vh;
}
body.dark-mode {
@ -30,7 +31,7 @@ body.dark-mode {
.header {
background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 15px 0;
position: sticky;
top: 0;
@ -38,23 +39,73 @@ body.dark-mode {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.header.dark-mode {
background-color: #2d2d2d;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.header h1 {
text-align: center;
font-size: 1.8rem;
font-size: 2rem;
color: #ff4500;
margin: 0;
flex-grow: 0;
flex-shrink: 0;
font-weight: 700;
}
.header.dark-mode h1 {
color: #ff6b35;
}
/* Search container */
.search-container {
display: flex;
align-items: center;
gap: 10px;
flex-grow: 1;
max-width: 500px;
}
.search-container input {
flex-grow: 1;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 1rem;
background-color: #f8f8f8;
transition: all 0.3s ease;
}
.search-container input.dark-mode {
background-color: #3a3a3a;
border-color: #444;
color: #e0e0e0;
}
.search-container input:focus {
outline: none;
border-color: #ff4500;
box-shadow: 0 0 0 2px rgba(255, 69, 0, 0.2);
}
.search-container input.dark-mode:focus {
border-color: #ff6b35;
box-shadow: 0 0 0 2px rgba(255, 107, 53, 0.2);
}
.search-icon {
color: #999;
font-size: 1.2rem;
}
.search-container .search-icon.dark-mode {
color: #aaa;
}
.content {
padding: 20px 0;
}
@ -68,45 +119,49 @@ body.dark-mode {
/* New rectangular post card design */
.post-card {
background-color: white;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
transition:
transform 0.2s,
box-shadow 0.2s;
transform 0.3s ease,
box-shadow 0.3s ease;
display: flex;
flex-direction: column;
min-height: 100px;
min-height: 120px;
border: 1px solid #eee;
}
.post-card.dark-mode {
background-color: #2d2d2d;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
border-color: #444;
}
.post-card:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.post-card.dark-mode:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.post-content {
padding: 12px;
padding: 16px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.post-title {
font-size: 1.1rem;
margin-bottom: 8px;
font-size: 1.2rem;
margin-bottom: 12px;
color: #000;
text-decoration: none;
display: block;
flex-grow: 1;
font-weight: 600;
line-height: 1.4;
}
.post-title.dark-mode {
@ -115,6 +170,7 @@ body.dark-mode {
.post-title:hover {
color: #ff4500;
text-decoration: underline;
}
.post-title.dark-mode:hover {
@ -126,23 +182,25 @@ body.dark-mode {
background-color: #f0f0f0;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.dark-mode-toggle.dark-mode {
background-color: #404040;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.dark-mode-toggle:hover {
background-color: #e0e0e0;
transform: scale(1.1);
}
.dark-mode-toggle.dark-mode:hover {
@ -150,14 +208,82 @@ body.dark-mode {
}
.dark-mode-icon {
font-size: 1.2rem;
transition: transform 0.3s;
font-size: 1.3rem;
transition: transform 0.3s ease;
}
.dark-mode-toggle.dark-mode .dark-mode-icon {
transform: rotate(180deg);
}
/* Post buttons */
.post-buttons {
display: flex;
gap: 8px;
padding: 0 16px 16px;
flex-wrap: wrap;
align-self: flex-start;
margin-top: 8px;
}
.post-button {
background-color: #ff4500;
color: white;
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.9rem;
transition: all 0.2s ease;
border: none;
cursor: pointer;
flex-shrink: 0;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.post-button.dark-mode {
background-color: #ff6b35;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.post-button:hover {
background-color: #e03d00;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.post-button.dark-mode:hover {
background-color: #ff7b45;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Search results counter */
.search-results {
margin: 15px 0;
font-size: 1.1rem;
font-weight: 500;
color: #666;
}
.search-results.dark-mode {
color: #aaa;
}
/* Loading and no more messages */
.loading,
.no-more {
text-align: center;
padding: 25px;
font-size: 1.2rem;
color: #666;
font-weight: 500;
}
.loading.dark-mode,
.no-more.dark-mode {
color: #aaa;
}
.post-image-container {
width: 100%;
height: 130px;
@ -232,11 +358,11 @@ body.dark-mode {
.post-content {
max-height: 15vh; /* About 1/6 screen height for wide displays */
overflow-y: auto;
padding: 12px;
padding: 16px;
}
.post-card {
min-height: 100px;
min-height: 120px;
}
}
@ -247,25 +373,53 @@ body.dark-mode {
}
.header {
padding: 10px 0;
padding: 12px 0;
gap: 10px;
}
.header h1 {
font-size: 1.5rem;
font-size: 1.6rem;
}
.search-container {
max-width: 100%;
width: 100%;
}
.post-content {
padding: 10px;
padding: 12px;
}
.post-title {
font-size: 1.1rem;
margin-bottom: 10px;
}
.post-buttons {
padding: 0 12px 12px;
}
.post-button {
padding: 6px 10px;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.header h1 {
font-size: 1.4rem;
}
.post-title {
font-size: 1rem;
margin-bottom: 6px;
}
.post-image-container {
height: 100px;
margin-bottom: 6px;
.post-card {
min-height: 100px;
}
.post-content {
padding: 10px;
}
.post-buttons {
@ -278,24 +432,6 @@ body.dark-mode {
}
}
@media (max-width: 480px) {
.post-image-container {
height: 80px;
}
.header h1 {
font-size: 1.3rem;
}
.post-title {
font-size: 0.9rem;
}
.post-card {
min-height: 90px;
}
}
/* Loading and no more messages */
.loading,
.no-more {