Compare commits
10 Commits
a35648d860
...
9664e945d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 9664e945d5 | |||
|
|
df9e37f7f1 | ||
|
|
d2ad449eb9 | ||
|
|
65d9771a13 | ||
|
|
7ada2278c6 | ||
|
|
432c162642 | ||
|
|
6786f982f2 | ||
|
|
70ce394302 | ||
|
|
eed0dd9ced | ||
|
|
57ac07730e |
143
.gitea/workflows/ci.yml
Normal file
143
.gitea/workflows/ci.yml
Normal 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"
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -66,4 +66,8 @@ cache/
|
|||||||
|
|
||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
._*
|
._*
|
||||||
|
|
||||||
|
venv/
|
||||||
|
bin/
|
||||||
|
share/
|
||||||
|
|||||||
56
Dockerfile
56
Dockerfile
@ -1,64 +1,22 @@
|
|||||||
# Multi-stage Dockerfile for Red Head Python Backend
|
# Simple 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
|
|
||||||
FROM python:3.9-slim
|
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
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy Python dependencies from builder stage (virtual environment)
|
# Copy requirements and install Python dependencies
|
||||||
COPY --from=builder /home/builder/venv /app/venv
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code and all files
|
||||||
COPY --chown=appuser:appuser src/server/ .
|
COPY . .
|
||||||
|
|
||||||
# Copy website files
|
|
||||||
COPY --chown=appuser:appuser src/website/ website/
|
|
||||||
|
|
||||||
# Create directory for cache if it doesn't exist
|
# Create directory for cache if it doesn't exist
|
||||||
RUN mkdir -p website/cache && \
|
RUN mkdir -p cache
|
||||||
chown -R appuser:appuser website/cache
|
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 6006
|
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
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||||
CMD curl -f http://localhost:6006/api || exit 1
|
CMD curl -f http://localhost:6006/api || exit 1
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
@ -1,6 +1,8 @@
|
|||||||
// Global variables
|
// Global variables
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let currentPostCount = 0;
|
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 POSTS_CACHE_KEY = "cached_posts_100";
|
||||||
const CACHE_EXPIRY_TIME = 5 * 60 * 1000; // 5 minutes
|
const CACHE_EXPIRY_TIME = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
@ -116,13 +118,36 @@ function createPostElement(post) {
|
|||||||
return postElement;
|
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) {
|
function renderPosts(posts) {
|
||||||
const container = document.getElementById("posts-container");
|
const container = document.getElementById("posts-container");
|
||||||
|
|
||||||
|
// Clear container
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
posts.forEach((post) => {
|
posts.forEach((post) => {
|
||||||
const postElement = createPostElement(post);
|
const postElement = createPostElement(post);
|
||||||
container.appendChild(postElement);
|
container.appendChild(postElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update search results counter
|
||||||
|
updateSearchResultsCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache management functions
|
// 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
|
// Extract real title from base_url
|
||||||
function extractRealTitleFromUrl(baseUrl) {
|
function extractRealTitleFromUrl(baseUrl) {
|
||||||
if (!baseUrl || typeof baseUrl !== "string") {
|
if (!baseUrl || typeof baseUrl !== "string") {
|
||||||
@ -236,7 +307,9 @@ async function loadInitialPosts() {
|
|||||||
|
|
||||||
if (cachedPosts && cachedPosts.length > 0) {
|
if (cachedPosts && cachedPosts.length > 0) {
|
||||||
console.log("Loading posts from cache");
|
console.log("Loading posts from cache");
|
||||||
renderPosts(cachedPosts);
|
allPosts = cachedPosts;
|
||||||
|
filteredPosts = [...allPosts];
|
||||||
|
renderPosts(filteredPosts);
|
||||||
currentPostCount = cachedPosts.length;
|
currentPostCount = cachedPosts.length;
|
||||||
|
|
||||||
// Check if we have more posts or not
|
// Check if we have more posts or not
|
||||||
@ -253,7 +326,9 @@ async function loadInitialPosts() {
|
|||||||
try {
|
try {
|
||||||
console.log("Fetching posts from API");
|
console.log("Fetching posts from API");
|
||||||
const posts = await fetchRedditPosts();
|
const posts = await fetchRedditPosts();
|
||||||
renderPosts(posts);
|
allPosts = posts;
|
||||||
|
filteredPosts = [...allPosts];
|
||||||
|
renderPosts(filteredPosts);
|
||||||
currentPostCount = posts.length;
|
currentPostCount = posts.length;
|
||||||
|
|
||||||
// Cache the posts
|
// Cache the posts
|
||||||
@ -284,7 +359,11 @@ async function loadMorePosts() {
|
|||||||
const morePosts = await fetchMorePosts(currentPostCount);
|
const morePosts = await fetchMorePosts(currentPostCount);
|
||||||
|
|
||||||
if (morePosts.length > 0) {
|
if (morePosts.length > 0) {
|
||||||
renderPosts(morePosts);
|
// Add new posts to allPosts and filteredPosts
|
||||||
|
allPosts = [...allPosts, ...morePosts];
|
||||||
|
filteredPosts = [...filteredPosts, ...morePosts];
|
||||||
|
|
||||||
|
renderPosts(filteredPosts);
|
||||||
currentPostCount += morePosts.length;
|
currentPostCount += morePosts.length;
|
||||||
} else {
|
} else {
|
||||||
// No more posts to load
|
// No more posts to load
|
||||||
@ -329,6 +408,7 @@ function setupInfiniteScroll() {
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
loadInitialPosts();
|
loadInitialPosts();
|
||||||
setupInfiniteScroll();
|
setupInfiniteScroll();
|
||||||
|
initializeSearch();
|
||||||
|
|
||||||
// Add event listener to dark mode toggle button
|
// Add event listener to dark mode toggle button
|
||||||
const darkModeToggle = document.getElementById("dark-mode-toggle");
|
const darkModeToggle = document.getElementById("dark-mode-toggle");
|
||||||
@ -1,10 +1,12 @@
|
|||||||
from flask import Flask, jsonify, request, send_from_directory
|
from flask import Flask, jsonify, request, send_from_directory, send_file
|
||||||
from flask_cors import CORS
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import logging
|
||||||
|
|
||||||
app = Flask(__name__)
|
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
|
# Import our archive parser
|
||||||
from parse_archive import ArchiveParser
|
from parse_archive import ArchiveParser
|
||||||
@ -12,51 +14,18 @@ from parse_archive import ArchiveParser
|
|||||||
# Initialize the archive parser
|
# Initialize the archive parser
|
||||||
archive_parser = ArchiveParser()
|
archive_parser = ArchiveParser()
|
||||||
|
|
||||||
# Validate archive directory at startup
|
# Serve static files from the root directory
|
||||||
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
|
|
||||||
@app.route("/<path:filename>")
|
@app.route("/<path:filename>")
|
||||||
def serve_static(filename):
|
def serve_static(filename):
|
||||||
# In Docker, the structure is: /app/src/server/ and /app/website/
|
try:
|
||||||
# So we need to go up from src/server to /app/ and then to /app/website
|
return send_from_directory(".", filename)
|
||||||
website_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "website")
|
except FileNotFoundError:
|
||||||
return send_from_directory(website_dir, filename)
|
return jsonify({"error": "File not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
# Serve the main index.html page
|
# Serve the main index.html page
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def serve_index():
|
def serve_index():
|
||||||
# In Docker, the structure is: /app/src/server/ and /app/website/
|
return send_file("index.html")
|
||||||
website_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "website")
|
|
||||||
return send_from_directory(website_dir, "index.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/posts", methods=["GET"])
|
@app.route("/posts", methods=["GET"])
|
||||||
def get_initial_posts():
|
def get_initial_posts():
|
||||||
@ -73,7 +42,6 @@ def get_initial_posts():
|
|||||||
print(f"Error: {e}")
|
print(f"Error: {e}")
|
||||||
return jsonify({"error": "Failed to fetch posts"}), 500
|
return jsonify({"error": "Failed to fetch posts"}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route("/posts/more", methods=["GET"])
|
@app.route("/posts/more", methods=["GET"])
|
||||||
def get_more_posts():
|
def get_more_posts():
|
||||||
"""Get next 10 posts based on the count parameter"""
|
"""Get next 10 posts based on the count parameter"""
|
||||||
@ -89,10 +57,9 @@ def get_more_posts():
|
|||||||
filters_posts += [p]
|
filters_posts += [p]
|
||||||
return jsonify(filters_posts)
|
return jsonify(filters_posts)
|
||||||
except Exception as e:
|
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
|
return jsonify({"error": "Failed to fetch more posts"}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route("/posts/total", methods=["GET"])
|
@app.route("/posts/total", methods=["GET"])
|
||||||
def get_total_posts():
|
def get_total_posts():
|
||||||
"""Get the total number of posts available"""
|
"""Get the total number of posts available"""
|
||||||
@ -101,10 +68,9 @@ def get_total_posts():
|
|||||||
total = archive_parser.get_total_posts()
|
total = archive_parser.get_total_posts()
|
||||||
return jsonify({"total": total})
|
return jsonify({"total": total})
|
||||||
except Exception as e:
|
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
|
return jsonify({"error": "Failed to fetch total count"}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api")
|
@app.route("/api")
|
||||||
def home():
|
def home():
|
||||||
"""Home endpoint to verify server is running"""
|
"""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/more?count=N": "Get next 10 posts, starting from index N",
|
||||||
"GET /posts/total": "Get total number of posts",
|
"GET /posts/total": "Get total number of posts",
|
||||||
},
|
},
|
||||||
|
"status": "healthy"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True, host="0.0.0.0", port=6006)
|
app.run(debug=True, host="0.0.0.0", port=6006)
|
||||||
@ -10,10 +10,7 @@ services:
|
|||||||
- ARCHIVE_DIR=/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/
|
- ARCHIVE_DIR=/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/server:/app/src/server
|
- .:/app
|
||||||
- ./src/website:/app/website
|
|
||||||
- ./website/cache:/app/website/cache
|
|
||||||
- /app/src/server/__pycache__
|
|
||||||
# Mount the actual archive directory
|
# Mount the actual archive directory
|
||||||
- /mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/:/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/
|
- /mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/:/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|||||||
5
etc/jupyter/nbconfig/notebook.d/widgetsnbextension.json
Normal file
5
etc/jupyter/nbconfig/notebook.d/widgetsnbextension.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"load_extensions": {
|
||||||
|
"jupyter-js-widgets/extension": true
|
||||||
|
}
|
||||||
|
}
|
||||||
164
include/site/python3.13/greenlet/greenlet.h
Normal file
164
include/site/python3.13/greenlet/greenlet.h
Normal 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 */
|
||||||
@ -10,6 +10,15 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>Red Head</h1>
|
<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
|
<button
|
||||||
id="dark-mode-toggle"
|
id="dark-mode-toggle"
|
||||||
class="dark-mode-toggle"
|
class="dark-mode-toggle"
|
||||||
@ -5,6 +5,8 @@ import urllib.parse
|
|||||||
import uuid
|
import uuid
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import threading
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@ -14,6 +16,10 @@ class ArchiveParser:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the ArchiveParser with archive_dir from environment variable."""
|
"""Initialize the ArchiveParser with archive_dir from environment variable."""
|
||||||
self.archive_dir = os.environ.get("ARCHIVE_DIR", "/default/archive/path")
|
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]]:
|
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
|
List[Dict[str, Any]]: List of post dictionaries
|
||||||
"""
|
"""
|
||||||
try:
|
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
|
# Use ls with tail and head commands for efficient pagination
|
||||||
# Get all directories, skip the first start_index, then get count number of entries
|
# 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}"
|
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
|
# Convert extracted posts to final format with proper IDs
|
||||||
formatted_posts = [self._create_post(post) for post in posts]
|
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
|
return formatted_posts
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
@ -119,6 +141,24 @@ class ArchiveParser:
|
|||||||
print(f"Error getting total posts: {e}")
|
print(f"Error getting total posts: {e}")
|
||||||
return 0
|
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:
|
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'."""
|
"""Extract the real title from base_url when title is 'Reddit - Prove your humanity'."""
|
||||||
if not base_url or not isinstance(base_url, str):
|
if not base_url or not isinstance(base_url, str):
|
||||||
@ -219,4 +259,4 @@ class ArchiveParser:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error converting index to post: {e}")
|
print(f"Error converting index to post: {e}")
|
||||||
# Return a minimal post if there's an error
|
# Return a minimal post if there's an error
|
||||||
raise e
|
raise e
|
||||||
5
pyvenv.cfg
Normal file
5
pyvenv.cfg
Normal 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
|
||||||
@ -2,4 +2,4 @@ Flask
|
|||||||
Flask-CORS
|
Flask-CORS
|
||||||
Pillow
|
Pillow
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
requests
|
requests
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@ -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()
|
|
||||||
@ -15,6 +15,7 @@ body {
|
|||||||
transition:
|
transition:
|
||||||
background-color 0.3s,
|
background-color 0.3s,
|
||||||
color 0.3s;
|
color 0.3s;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode {
|
body.dark-mode {
|
||||||
@ -30,7 +31,7 @@ body.dark-mode {
|
|||||||
|
|
||||||
.header {
|
.header {
|
||||||
background-color: white;
|
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;
|
padding: 15px 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -38,23 +39,73 @@ body.dark-mode {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header.dark-mode {
|
.header.dark-mode {
|
||||||
background-color: #2d2d2d;
|
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 {
|
.header h1 {
|
||||||
text-align: center;
|
font-size: 2rem;
|
||||||
font-size: 1.8rem;
|
|
||||||
color: #ff4500;
|
color: #ff4500;
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header.dark-mode h1 {
|
.header.dark-mode h1 {
|
||||||
color: #ff6b35;
|
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 {
|
.content {
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
@ -68,45 +119,49 @@ body.dark-mode {
|
|||||||
/* New rectangular post card design */
|
/* New rectangular post card design */
|
||||||
.post-card {
|
.post-card {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 4px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition:
|
transition:
|
||||||
transform 0.2s,
|
transform 0.3s ease,
|
||||||
box-shadow 0.2s;
|
box-shadow 0.3s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100px;
|
min-height: 120px;
|
||||||
|
border: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-card.dark-mode {
|
.post-card.dark-mode {
|
||||||
background-color: #2d2d2d;
|
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 {
|
.post-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-card.dark-mode:hover {
|
.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 {
|
.post-content {
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-title {
|
.post-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.2rem;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 12px;
|
||||||
color: #000;
|
color: #000;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: block;
|
display: block;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-title.dark-mode {
|
.post-title.dark-mode {
|
||||||
@ -115,6 +170,7 @@ body.dark-mode {
|
|||||||
|
|
||||||
.post-title:hover {
|
.post-title:hover {
|
||||||
color: #ff4500;
|
color: #ff4500;
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-title.dark-mode:hover {
|
.post-title.dark-mode:hover {
|
||||||
@ -126,23 +182,25 @@ body.dark-mode {
|
|||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: background-color 0.3s;
|
transition: all 0.3s ease;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode-toggle.dark-mode {
|
.dark-mode-toggle.dark-mode {
|
||||||
background-color: #404040;
|
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 {
|
.dark-mode-toggle:hover {
|
||||||
background-color: #e0e0e0;
|
background-color: #e0e0e0;
|
||||||
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode-toggle.dark-mode:hover {
|
.dark-mode-toggle.dark-mode:hover {
|
||||||
@ -150,14 +208,82 @@ body.dark-mode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode-icon {
|
.dark-mode-icon {
|
||||||
font-size: 1.2rem;
|
font-size: 1.3rem;
|
||||||
transition: transform 0.3s;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode-toggle.dark-mode .dark-mode-icon {
|
.dark-mode-toggle.dark-mode .dark-mode-icon {
|
||||||
transform: rotate(180deg);
|
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 {
|
.post-image-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 130px;
|
height: 130px;
|
||||||
@ -232,11 +358,11 @@ body.dark-mode {
|
|||||||
.post-content {
|
.post-content {
|
||||||
max-height: 15vh; /* About 1/6 screen height for wide displays */
|
max-height: 15vh; /* About 1/6 screen height for wide displays */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-card {
|
.post-card {
|
||||||
min-height: 100px;
|
min-height: 120px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,25 +373,53 @@ body.dark-mode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
padding: 10px 0;
|
padding: 12px 0;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-content {
|
.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 {
|
.post-title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-image-container {
|
.post-card {
|
||||||
height: 100px;
|
min-height: 100px;
|
||||||
margin-bottom: 6px;
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-buttons {
|
.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 and no more messages */
|
||||||
.loading,
|
.loading,
|
||||||
.no-more {
|
.no-more {
|
||||||
Loading…
x
Reference in New Issue
Block a user