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
|
||||
.DS_Store
|
||||
._*
|
||||
._*
|
||||
|
||||
venv/
|
||||
bin/
|
||||
share/
|
||||
|
||||
56
Dockerfile
56
Dockerfile
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
@ -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");
|
||||
@ -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)
|
||||
app.run(debug=True, host="0.0.0.0", port=6006)
|
||||
@ -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
|
||||
|
||||
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">
|
||||
<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"
|
||||
@ -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):
|
||||
@ -219,4 +259,4 @@ class ArchiveParser:
|
||||
except Exception as e:
|
||||
print(f"Error converting index to post: {e}")
|
||||
# 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
|
||||
Pillow
|
||||
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:
|
||||
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 {
|
||||
Loading…
x
Reference in New Issue
Block a user