Compare commits
17 Commits
fb6ce5a103
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cd27cc8ad9 | |||
| b08b81fec9 | |||
| d1edf8fb50 | |||
| 36e0ab9a88 | |||
| 720b89a296 | |||
| 4b31d3b9e0 | |||
| 3dbb181cb0 | |||
| f409977257 | |||
| 0b1241714d | |||
|
|
58884e119d | ||
|
|
875fd9a6b2 | ||
|
|
63a9144c7f | ||
| 915febb352 | |||
| 8c036a5a43 | |||
| 9f4d380950 | |||
| 449c3a2dc2 | |||
| 4a6bcd8390 |
30
.gitignore
vendored
30
.gitignore
vendored
@@ -1,2 +1,28 @@
|
||||
venv
|
||||
instance
|
||||
# Virtual Environment
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Flask instance folder - keep the folder but ignore sensitive files
|
||||
instance/flask_secret_key
|
||||
instance/*.log
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
|
||||
# Database WAL files (keep main .db file)
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
148
CONFIG_GUIDE.md
Normal file
148
CONFIG_GUIDE.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Configuration Guide
|
||||
|
||||
This guide explains how to configure the ircquotes application by editing `config.json` manually.
|
||||
|
||||
## Configuration File Structure
|
||||
|
||||
The `config.json` file is organized into sections:
|
||||
|
||||
### App Section
|
||||
```json
|
||||
"app": {
|
||||
"name": "ircquotes", // Application name
|
||||
"host": "127.0.0.1", // Host to bind to (use 0.0.0.0 for all interfaces)
|
||||
"port": 6969, // Port number to run on
|
||||
"debug": false // Enable debug mode (set to true for development)
|
||||
}
|
||||
```
|
||||
|
||||
### Gunicorn Section (Production Settings)
|
||||
```json
|
||||
"gunicorn": {
|
||||
"workers": 4, // Number of worker processes
|
||||
"timeout": 30, // Request timeout in seconds
|
||||
"keepalive": 5, // Keep-alive timeout
|
||||
"max_requests": 1000, // Max requests per worker before restart
|
||||
"preload": true // Preload application code
|
||||
}
|
||||
```
|
||||
|
||||
### Database Section
|
||||
```json
|
||||
"database": {
|
||||
"uri": "sqlite:///quotes.db?timeout=20", // Database connection string
|
||||
"pool_timeout": 20, // Connection pool timeout
|
||||
"pool_recycle": -1, // Connection recycle time (-1 = disabled)
|
||||
"pool_pre_ping": true // Test connections before use
|
||||
}
|
||||
```
|
||||
|
||||
### Security Section
|
||||
```json
|
||||
"security": {
|
||||
"csrf_enabled": true, // Enable CSRF protection
|
||||
"csrf_time_limit": null, // CSRF token time limit (null = no limit)
|
||||
"session_cookie_secure": false, // Require HTTPS for session cookies
|
||||
"session_cookie_httponly": true, // Prevent JavaScript access to session cookies
|
||||
"session_cookie_samesite": "Lax", // SameSite policy for session cookies
|
||||
"security_headers": {
|
||||
"x_content_type_options": "nosniff",
|
||||
"x_frame_options": "DENY",
|
||||
"x_xss_protection": "1; mode=block",
|
||||
"strict_transport_security": "max-age=31536000; includeSubDomains",
|
||||
"content_security_policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Section
|
||||
```json
|
||||
"admin": {
|
||||
"username": "admin", // Admin username
|
||||
"password_hash": "..." // Argon2 password hash (use generate_password.py)
|
||||
}
|
||||
```
|
||||
|
||||
### Quotes Section
|
||||
```json
|
||||
"quotes": {
|
||||
"min_length": 1, // Minimum quote length in characters
|
||||
"max_length": 5000, // Maximum quote length in characters
|
||||
"per_page": 25, // Quotes displayed per page
|
||||
"auto_approve": false, // Automatically approve new quotes
|
||||
"allow_html": false // Allow HTML in quotes (not recommended)
|
||||
}
|
||||
```
|
||||
|
||||
### Features Section
|
||||
```json
|
||||
"features": {
|
||||
"voting_enabled": true, // Enable voting on quotes
|
||||
"flagging_enabled": true, // Enable flagging inappropriate quotes
|
||||
"copy_quotes_enabled": true, // Enable copy-to-clipboard feature
|
||||
"dark_mode_enabled": true, // Enable dark mode toggle
|
||||
"api_enabled": true, // Enable JSON API endpoints
|
||||
"bulk_moderation_enabled": true // Enable bulk moderation actions
|
||||
}
|
||||
```
|
||||
|
||||
### Logging Section
|
||||
```json
|
||||
"logging": {
|
||||
"level": "DEBUG", // Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
"format": "%(asctime)s [%(levelname)s] %(message)s" // Log message format
|
||||
}
|
||||
```
|
||||
|
||||
## Common Configuration Tasks
|
||||
|
||||
### Change Admin Password
|
||||
1. Run: `python generate_password.py`
|
||||
2. Edit `config.json` and update `admin.password_hash` with the generated hash
|
||||
3. Restart the application
|
||||
|
||||
### Change Port
|
||||
Edit the `app.port` value in `config.json`:
|
||||
```json
|
||||
"app": {
|
||||
"port": 8080
|
||||
}
|
||||
```
|
||||
|
||||
### Adjust Quote Limits
|
||||
Edit the `quotes` section:
|
||||
```json
|
||||
"quotes": {
|
||||
"min_length": 10,
|
||||
"max_length": 2000,
|
||||
"per_page": 50
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Features
|
||||
Set feature flags to `false`:
|
||||
```json
|
||||
"features": {
|
||||
"voting_enabled": false,
|
||||
"flagging_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### Adjust Rate Limits
|
||||
Modify the `rate_limiting.endpoints` section:
|
||||
```json
|
||||
"rate_limiting": {
|
||||
"endpoints": {
|
||||
"submit": "10 per minute",
|
||||
"vote": "120 per minute"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always restart the application after making configuration changes
|
||||
- Use valid JSON syntax (no trailing commas, proper quotes)
|
||||
- Test configuration changes in a development environment first
|
||||
- Keep backups of your working configuration
|
||||
- Use `python generate_password.py` to create secure password hashes
|
||||
139
DEPLOYMENT.md
Normal file
139
DEPLOYMENT.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# ircquotes Production Deployment
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Configuration File: `config.json`
|
||||
All application settings are now centralized in `config.json`. You can easily modify:
|
||||
|
||||
- **App settings** (host, port, debug mode)
|
||||
- **Database configuration** (URI, connection pool settings)
|
||||
- **Security settings** (CSRF, session cookies, security headers)
|
||||
- **Rate limiting** (per-endpoint limits)
|
||||
- **Quote settings** (length limits, pagination)
|
||||
- **Admin credentials**
|
||||
- **Feature toggles**
|
||||
|
||||
### Configuration Management
|
||||
All configuration is done by editing `config.json` directly. This file contains all application settings organized in sections:
|
||||
|
||||
- **app**: Basic application settings (name, host, port, debug)
|
||||
- **database**: Database connection settings
|
||||
- **security**: Security headers, CSRF, proxy settings
|
||||
- **rate_limiting**: Rate limiting configuration for different endpoints
|
||||
- **admin**: Admin username and password hash
|
||||
- **quotes**: Quote submission settings (length limits, pagination)
|
||||
- **features**: Feature toggles (voting, flagging, dark mode, etc.)
|
||||
- **logging**: Logging configuration
|
||||
|
||||
### Example Configuration Changes
|
||||
```bash
|
||||
# Edit config.json in any text editor
|
||||
nano config.json
|
||||
|
||||
# Example changes:
|
||||
# - Change port: "port": 8080 in the "app" section
|
||||
# - Change quotes per page: "per_page": 50 in the "quotes" section
|
||||
# - Disable CSRF: "csrf_enabled": false in the "security" section
|
||||
# - Change rate limits: "login": "10 per minute" in rate_limiting.endpoints
|
||||
|
||||
# After making changes, restart the application
|
||||
```
|
||||
|
||||
## Running with Gunicorn (Production)
|
||||
|
||||
### Quick Start - Uses config.json settings
|
||||
```bash
|
||||
# Activate virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Option 1: Run with config file (recommended - uses config.json)
|
||||
gunicorn --config gunicorn.conf.py app:app
|
||||
|
||||
# Option 2: Run with Python launcher (also uses config.json)
|
||||
python start_gunicorn.py
|
||||
```
|
||||
|
||||
### Manual Gunicorn Commands (ignores config.json)
|
||||
|
||||
**Basic production run:**
|
||||
```bash
|
||||
gunicorn -w 4 -b 127.0.0.1:6969 app:app
|
||||
```
|
||||
|
||||
**With more workers (for higher traffic):**
|
||||
```bash
|
||||
gunicorn -w 8 -b 127.0.0.1:6969 --timeout 30 app:app
|
||||
```
|
||||
|
||||
**Behind a reverse proxy (nginx/apache):**
|
||||
```bash
|
||||
gunicorn -w 4 -b 127.0.0.1:6969 app:app
|
||||
```
|
||||
|
||||
### Environment Variables for Production
|
||||
```bash
|
||||
export FLASK_ENV=production
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- All major security vulnerabilities have been fixed
|
||||
- CSRF protection enabled
|
||||
- XSS protection with output escaping
|
||||
- SQL injection prevention
|
||||
- Rate limiting on all endpoints
|
||||
- Secure session configuration
|
||||
- Security headers added
|
||||
|
||||
## Admin Access
|
||||
- Username: Configurable in `config.json` (default: admin)
|
||||
- Password: Use the Argon2 hashed password in `config.json`
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### High-Traffic Setup
|
||||
```json
|
||||
{
|
||||
"quotes": {
|
||||
"per_page": 50
|
||||
},
|
||||
"rate_limiting": {
|
||||
"endpoints": {
|
||||
"vote": "120 per minute",
|
||||
"search": "60 per minute"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
```json
|
||||
{
|
||||
"app": {
|
||||
"debug": true,
|
||||
"port": 5000
|
||||
},
|
||||
"security": {
|
||||
"session_cookie_secure": false
|
||||
},
|
||||
"logging": {
|
||||
"level": "DEBUG"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Production Security Setup
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"session_cookie_secure": true,
|
||||
"csrf_enabled": true
|
||||
},
|
||||
"logging": {
|
||||
"level": "WARNING"
|
||||
}
|
||||
}
|
||||
```
|
||||
58
config.json
Normal file
58
config.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "ircquotes",
|
||||
"host": "127.0.0.1",
|
||||
"port": 6969,
|
||||
"debug": true
|
||||
},
|
||||
"gunicorn": {
|
||||
"workers": 1,
|
||||
"timeout": 30,
|
||||
"keepalive": 5,
|
||||
"max_requests": 1000,
|
||||
"preload": true
|
||||
},
|
||||
"database": {
|
||||
"uri": "sqlite:///quotes.db?timeout=20",
|
||||
"pool_timeout": 20,
|
||||
"pool_recycle": -1,
|
||||
"pool_pre_ping": true
|
||||
},
|
||||
"security": {
|
||||
"csrf_enabled": true,
|
||||
"csrf_time_limit": null,
|
||||
"session_cookie_secure": false,
|
||||
"session_cookie_httponly": true,
|
||||
"session_cookie_samesite": "Lax",
|
||||
"security_headers": {
|
||||
"x_content_type_options": "nosniff",
|
||||
"x_frame_options": "DENY",
|
||||
"x_xss_protection": "1; mode=block",
|
||||
"strict_transport_security": "max-age=31536000; includeSubDomains",
|
||||
"content_security_policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"username": "ComputerTech",
|
||||
"password_hash": "$argon2id$v=19$m=65536,t=3,p=4$cIPRCJrjS1DwjaFov5G+BQ$yundbpf2i1jBrKsj96ra7wTNmVZ56SJR25XX4jp2yR8"
|
||||
},
|
||||
"quotes": {
|
||||
"min_length": 1,
|
||||
"max_length": 5000,
|
||||
"per_page": 25,
|
||||
"auto_approve": false,
|
||||
"allow_html": false
|
||||
},
|
||||
"features": {
|
||||
"voting_enabled": true,
|
||||
"flagging_enabled": true,
|
||||
"copy_quotes_enabled": true,
|
||||
"dark_mode_enabled": true,
|
||||
"api_enabled": true,
|
||||
"bulk_moderation_enabled": true
|
||||
},
|
||||
"logging": {
|
||||
"level": "DEBUG",
|
||||
"format": "%(asctime)s [%(levelname)s] %(message)s"
|
||||
}
|
||||
}
|
||||
104
config_loader.py
Normal file
104
config_loader.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Configuration loader for ircquotes application.
|
||||
Loads settings from config.json and provides easy access to configuration values.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
class Config:
|
||||
"""Configuration manager for ircquotes application."""
|
||||
|
||||
def __init__(self, config_file: str = "config.json"):
|
||||
"""Initialize configuration from JSON file."""
|
||||
self.config_file = config_file
|
||||
self._config = self._load_config()
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from JSON file."""
|
||||
if not os.path.exists(self.config_file):
|
||||
raise FileNotFoundError(f"Configuration file {self.config_file} not found")
|
||||
|
||||
try:
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in configuration file: {e}")
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value using dot notation (e.g., 'app.host')."""
|
||||
keys = key.split('.')
|
||||
value = self._config
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
def get_section(self, section: str) -> Dict[str, Any]:
|
||||
"""Get entire configuration section."""
|
||||
return self._config.get(section, {})
|
||||
|
||||
def reload(self):
|
||||
"""Reload configuration from file."""
|
||||
self._config = self._load_config()
|
||||
|
||||
# Convenience properties for commonly used settings
|
||||
@property
|
||||
def app_name(self) -> str:
|
||||
return self.get('app.name', 'ircquotes')
|
||||
|
||||
@property
|
||||
def app_host(self) -> str:
|
||||
return self.get('app.host', '0.0.0.0')
|
||||
|
||||
@property
|
||||
def app_port(self) -> int:
|
||||
return self.get('app.port', 5050)
|
||||
|
||||
@property
|
||||
def debug_mode(self) -> bool:
|
||||
return self.get('app.debug', False)
|
||||
|
||||
@property
|
||||
def database_uri(self) -> str:
|
||||
return self.get('database.uri', 'sqlite:///quotes.db')
|
||||
|
||||
@property
|
||||
def csrf_enabled(self) -> bool:
|
||||
return self.get('security.csrf_enabled', True)
|
||||
|
||||
@property
|
||||
def rate_limiting_enabled(self) -> bool:
|
||||
return self.get('rate_limiting.enabled', True)
|
||||
|
||||
@property
|
||||
def quotes_per_page(self) -> int:
|
||||
return self.get('quotes.per_page', 25)
|
||||
|
||||
@property
|
||||
def min_quote_length(self) -> int:
|
||||
return self.get('quotes.min_length', 10)
|
||||
|
||||
@property
|
||||
def max_quote_length(self) -> int:
|
||||
return self.get('quotes.max_length', 5000)
|
||||
|
||||
@property
|
||||
def admin_username(self) -> str:
|
||||
return self.get('admin.username', 'admin')
|
||||
|
||||
@property
|
||||
def admin_password_hash(self) -> str:
|
||||
return self.get('admin.password_hash', '')
|
||||
|
||||
@property
|
||||
def logging_level(self) -> str:
|
||||
return self.get('logging.level', 'WARNING')
|
||||
|
||||
# Global configuration instance
|
||||
config = Config()
|
||||
77
create_fresh_db.py
Normal file
77
create_fresh_db.py
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a fresh quotes database with test data"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
# Remove existing database files
|
||||
db_files = ['instance/quotes.db', 'instance/quotes.db-shm', 'instance/quotes.db-wal']
|
||||
for db_file in db_files:
|
||||
if os.path.exists(db_file):
|
||||
os.remove(db_file)
|
||||
print(f"Removed {db_file}")
|
||||
|
||||
# Create fresh database
|
||||
conn = sqlite3.connect('instance/quotes.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create the quote table with proper schema
|
||||
cursor.execute("""
|
||||
CREATE TABLE quote (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
votes INTEGER DEFAULT 0,
|
||||
date DATETIME,
|
||||
status INTEGER DEFAULT 0,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
submitted_at DATETIME,
|
||||
flag_count INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for performance
|
||||
cursor.execute("CREATE INDEX idx_status_id ON quote(status, id)")
|
||||
cursor.execute("CREATE INDEX idx_flag_count_id ON quote(flag_count, id)")
|
||||
|
||||
# Insert test data
|
||||
test_quotes = [
|
||||
("This is a pending quote for testing moderation", 0, 0), # pending
|
||||
("This is an approved quote that should appear in browse", 5, 1), # approved
|
||||
("Another approved quote with positive votes", 12, 1), # approved
|
||||
("A rejected quote that was not good enough", -2, 2), # rejected
|
||||
("Another pending quote to test approve/reject", 0, 0), # pending
|
||||
("Third pending quote for comprehensive testing", 0, 0), # pending
|
||||
]
|
||||
|
||||
current_time = datetime.now()
|
||||
|
||||
for i, (text, votes, status) in enumerate(test_quotes, 1):
|
||||
cursor.execute("""
|
||||
INSERT INTO quote (text, votes, status, submitted_at, ip_address, user_agent, flag_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (text, votes, status, current_time, '127.0.0.1', 'Test Script', 0))
|
||||
|
||||
# Set WAL mode for better concurrency
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=1000")
|
||||
|
||||
# Commit and close
|
||||
conn.commit()
|
||||
|
||||
# Verify the data
|
||||
cursor.execute("SELECT id, text, status FROM quote ORDER BY id")
|
||||
results = cursor.fetchall()
|
||||
|
||||
print("\nCreated fresh database with test quotes:")
|
||||
print("ID | Status | Text")
|
||||
print("-" * 50)
|
||||
for quote_id, text, status in results:
|
||||
status_name = {0: "PENDING", 1: "APPROVED", 2: "REJECTED"}[status]
|
||||
print(f"{quote_id:2d} | {status_name:8s} | {text[:40]}...")
|
||||
|
||||
conn.close()
|
||||
print(f"\nFresh database created successfully!")
|
||||
print(f"Total quotes: {len(test_quotes)}")
|
||||
print("3 pending, 2 approved, 1 rejected")
|
||||
55
fix_password.py
Normal file
55
fix_password.py
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fix corrupted admin password hash in config.json
|
||||
"""
|
||||
|
||||
import json
|
||||
from argon2 import PasswordHasher
|
||||
import getpass
|
||||
|
||||
def fix_admin_password():
|
||||
"""Fix the corrupted admin password hash"""
|
||||
|
||||
print("Current admin password hash appears to be corrupted.")
|
||||
print("Let's generate a new one...")
|
||||
|
||||
# Get new password
|
||||
password = getpass.getpass("Enter new admin password: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
|
||||
if password != confirm:
|
||||
print("Passwords don't match!")
|
||||
return
|
||||
|
||||
# Generate new hash
|
||||
ph = PasswordHasher()
|
||||
new_hash = ph.hash(password)
|
||||
|
||||
# Load current config
|
||||
try:
|
||||
with open('config.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error reading config.json: {e}")
|
||||
return
|
||||
|
||||
# Update the password hash
|
||||
if 'admin' not in config:
|
||||
config['admin'] = {}
|
||||
|
||||
config['admin']['password_hash'] = new_hash
|
||||
|
||||
# Save config
|
||||
try:
|
||||
with open('config.json', 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print("\nPassword hash updated successfully!")
|
||||
print(f"New hash: {new_hash}")
|
||||
print("\nRestart the application for changes to take effect.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving config.json: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
fix_admin_password()
|
||||
40
generate_password.py
Normal file
40
generate_password.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Password hash generator for ircquotes admin.
|
||||
Generates Argon2 password hashes for secure storage.
|
||||
"""
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
def generate_password_hash():
|
||||
"""Generate an Argon2 password hash."""
|
||||
ph = PasswordHasher()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
# Password provided as argument
|
||||
password = sys.argv[1]
|
||||
else:
|
||||
# Prompt for password securely
|
||||
password = getpass.getpass("Enter admin password: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
|
||||
if password != confirm:
|
||||
print("Passwords don't match!")
|
||||
return
|
||||
|
||||
# Generate hash
|
||||
hash_value = ph.hash(password)
|
||||
|
||||
print("\nGenerated password hash:")
|
||||
print(hash_value)
|
||||
print("\nTo set this as admin password:")
|
||||
print("1. Open config.json in a text editor")
|
||||
print("2. Find the 'admin' section")
|
||||
print("3. Replace the 'password_hash' value with:")
|
||||
print(f' "{hash_value}"')
|
||||
print("4. Save the file and restart the application")
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_password_hash()
|
||||
2
instance/README.md
Normal file
2
instance/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file will be generated automatically on each server
|
||||
# Run: python -c "import secrets; print(secrets.token_hex(32))" > instance/flask_secret_key
|
||||
BIN
instance/quotes.db
Normal file
BIN
instance/quotes.db
Normal file
Binary file not shown.
113
nginx-ircquotes.conf
Normal file
113
nginx-ircquotes.conf
Normal file
@@ -0,0 +1,113 @@
|
||||
# nginx configuration for ircquotes behind Cloudflare
|
||||
# Place this in /etc/nginx/sites-available/ircquotes
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com; # Replace with your actual domain
|
||||
|
||||
# Cloudflare real IP restoration
|
||||
# Updated Cloudflare IP ranges (as of September 2025)
|
||||
# IPv4 ranges
|
||||
set_real_ip_from 173.245.48.0/20;
|
||||
set_real_ip_from 103.21.244.0/22;
|
||||
set_real_ip_from 103.22.200.0/22;
|
||||
set_real_ip_from 103.31.4.0/22;
|
||||
set_real_ip_from 141.101.64.0/18;
|
||||
set_real_ip_from 108.162.192.0/18;
|
||||
set_real_ip_from 190.93.240.0/20;
|
||||
set_real_ip_from 188.114.96.0/20;
|
||||
set_real_ip_from 197.234.240.0/22;
|
||||
set_real_ip_from 198.41.128.0/17;
|
||||
set_real_ip_from 162.158.0.0/15;
|
||||
set_real_ip_from 104.16.0.0/13;
|
||||
set_real_ip_from 104.24.0.0/14;
|
||||
set_real_ip_from 172.64.0.0/13;
|
||||
set_real_ip_from 131.0.72.0/22;
|
||||
|
||||
# IPv6 ranges
|
||||
set_real_ip_from 2400:cb00::/32;
|
||||
set_real_ip_from 2606:4700::/32;
|
||||
set_real_ip_from 2803:f800::/32;
|
||||
set_real_ip_from 2405:b500::/32;
|
||||
set_real_ip_from 2405:8100::/32;
|
||||
set_real_ip_from 2a06:98c0::/29;
|
||||
set_real_ip_from 2c0f:f248::/32;
|
||||
|
||||
# Use Cloudflare's CF-Connecting-IP header for real IP
|
||||
real_ip_header CF-Connecting-IP;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private must-revalidate auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript;
|
||||
|
||||
# Rate limiting (additional layer)
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
|
||||
|
||||
# Main application
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5050;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Pass Cloudflare headers
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
proxy_set_header CF-Ray $http_cf_ray;
|
||||
proxy_set_header CF-Visitor $http_cf_visitor;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Rate limit login endpoint
|
||||
location /login {
|
||||
limit_req zone=login burst=3 nodelay;
|
||||
proxy_pass http://127.0.0.1:5050;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
}
|
||||
|
||||
# Rate limit API endpoints
|
||||
location /api/ {
|
||||
limit_req zone=api burst=10 nodelay;
|
||||
proxy_pass http://127.0.0.1:5050;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
}
|
||||
|
||||
# Static files (optional optimization)
|
||||
location /static/ {
|
||||
proxy_pass http://127.0.0.1:5050;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# Cache static files
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
proxy_pass http://127.0.0.1:5050;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
83
production.py
Executable file
83
production.py
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Production launcher for ircquotes using Gunicorn
|
||||
Reads all configuration from config.json
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from config_loader import config
|
||||
|
||||
def main():
|
||||
"""Launch Gunicorn with settings from config.json"""
|
||||
|
||||
print("Starting ircquotes in production mode with Gunicorn...")
|
||||
|
||||
# Get configuration values from config.json
|
||||
host = config.app_host
|
||||
port = config.app_port
|
||||
workers = config.get('gunicorn.workers', 1) # Default to 1 to avoid SQLite locking
|
||||
timeout = config.get('gunicorn.timeout', 30)
|
||||
keepalive = config.get('gunicorn.keepalive', 5)
|
||||
max_requests = config.get('gunicorn.max_requests', 1000)
|
||||
preload = config.get('gunicorn.preload', True)
|
||||
|
||||
# Use virtual environment's gunicorn if available
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
venv_gunicorn = os.path.join(script_dir, '.venv', 'bin', 'gunicorn')
|
||||
|
||||
if os.path.exists(venv_gunicorn):
|
||||
gunicorn_cmd = venv_gunicorn
|
||||
print(f"Using virtual environment Gunicorn: {venv_gunicorn}")
|
||||
else:
|
||||
gunicorn_cmd = 'gunicorn'
|
||||
print("Using system Gunicorn")
|
||||
|
||||
# Build Gunicorn command with all config.json settings
|
||||
cmd = [
|
||||
gunicorn_cmd,
|
||||
'--bind', f'{host}:{port}',
|
||||
'--workers', str(workers),
|
||||
'--timeout', str(timeout),
|
||||
'--keep-alive', str(keepalive), # Fixed: --keep-alive not --keepalive
|
||||
'--max-requests', str(max_requests),
|
||||
'--max-requests-jitter', '100',
|
||||
'--access-logfile', '-', # Log to stdout
|
||||
'--error-logfile', '-', # Log to stderr
|
||||
'--log-level', 'info'
|
||||
]
|
||||
|
||||
# Add preload option if enabled
|
||||
if preload:
|
||||
cmd.append('--preload')
|
||||
|
||||
# Add the app module at the end
|
||||
cmd.append('app:app')
|
||||
|
||||
print(f"Configuration:")
|
||||
print(f" Host: {host}")
|
||||
print(f" Port: {port}")
|
||||
print(f" Workers: {workers}")
|
||||
print(f" Timeout: {timeout}s")
|
||||
print(f" Max Requests: {max_requests}")
|
||||
print(f" Preload: {preload}")
|
||||
print()
|
||||
print(f"Gunicorn command: {' '.join(cmd)}")
|
||||
print()
|
||||
|
||||
# Execute Gunicorn
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error starting Gunicorn: {e}")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping Gunicorn...")
|
||||
sys.exit(0)
|
||||
except FileNotFoundError:
|
||||
print("Error: Gunicorn not found. Please install it with: pip install gunicorn")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,6 @@
|
||||
Flask==2.3.2
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
Flask-Limiter==2.4
|
||||
Flask-CORS==3.0.10
|
||||
Flask-WTF==1.2.1
|
||||
argon2-cffi==21.3.0
|
||||
gunicorn==21.2.0
|
||||
|
||||
57
setup.sh
Normal file
57
setup.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ircquotes production server setup script
|
||||
# Run this after cloning the repository
|
||||
|
||||
echo "Setting up ircquotes on production server..."
|
||||
|
||||
# Instance directory should already exist from git
|
||||
# But create it if it doesn't
|
||||
mkdir -p instance
|
||||
|
||||
# Generate secret key
|
||||
echo "Generating Flask secret key..."
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))" > instance/flask_secret_key
|
||||
|
||||
# Create empty database file if it doesn't exist
|
||||
if [ ! -f "instance/quotes.db" ]; then
|
||||
echo "Creating database file..."
|
||||
touch instance/quotes.db
|
||||
fi
|
||||
|
||||
# Set permissions
|
||||
echo "Setting file permissions..."
|
||||
chmod 600 instance/flask_secret_key
|
||||
chmod 664 instance/quotes.db
|
||||
|
||||
# Create virtual environment
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv .venv
|
||||
|
||||
# Activate and install dependencies
|
||||
echo "Installing dependencies..."
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Initialize database
|
||||
echo "Initializing database..."
|
||||
python -c "from app import app, db; app.app_context().push(); db.create_all(); print('Database initialized successfully!')"
|
||||
|
||||
echo ""
|
||||
echo "Setup complete! You can now:"
|
||||
echo "1. Configure admin credentials:"
|
||||
echo " python generate_password.py"
|
||||
echo " # Then edit config.json and update admin.username and admin.password_hash"
|
||||
echo ""
|
||||
echo "2. Configure other settings by editing config.json:"
|
||||
echo " # app.port - Change server port"
|
||||
echo " # quotes.min_length - Minimum quote length"
|
||||
echo " # quotes.max_length - Maximum quote length"
|
||||
echo " # security.csrf_enabled - Enable/disable CSRF protection"
|
||||
echo ""
|
||||
echo "3. Start the application:"
|
||||
echo " source .venv/bin/activate"
|
||||
echo " gunicorn --config gunicorn.conf.py app:app"
|
||||
echo ""
|
||||
echo "4. Or run in development mode:"
|
||||
echo " python app.py"
|
||||
@@ -1,234 +0,0 @@
|
||||
/* Global Styles */
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: #c08000;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
a:visited, a:link, a:hover {
|
||||
color: #c08000;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Table and Layout */
|
||||
table {
|
||||
width: 80%;
|
||||
margin: 20px auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
h2 {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
color: #c08000;
|
||||
}
|
||||
|
||||
/* Text Classes */
|
||||
.smalltext {
|
||||
font-size: 10px;
|
||||
background-color: #f0f0f0;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.bodytext {
|
||||
line-height: 21px;
|
||||
font-size: smaller;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.footertext {
|
||||
font-size: smaller;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toplinks {
|
||||
font-size: smaller;
|
||||
color: #000000;
|
||||
text-align: right;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.topnum {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Input Fields */
|
||||
input.text, textarea, select {
|
||||
color: #000000;
|
||||
background-color: #ffffff;
|
||||
padding: 5px;
|
||||
border: 1px solid #c08000;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
input.button {
|
||||
background-color: #c08000;
|
||||
color: #000000;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
input.button:hover {
|
||||
background-color: #a06500;
|
||||
}
|
||||
|
||||
/* Quote Styling */
|
||||
.qt {
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
font-size: 10pt;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.qa {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 8pt;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.quote {
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
font-size: smaller;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Borders for Quotes and Other Content */
|
||||
td.quote-box {
|
||||
border: 1px solid #c08000;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: smaller;
|
||||
background-color: #c08000;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
#pagination {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
#pagination a {
|
||||
color: #c08000;
|
||||
text-decoration: none;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #c08000;
|
||||
margin: 0 5px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
#pagination a:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Quote List */
|
||||
.quote-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.quote-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.quote-id {
|
||||
color: #c08000;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.quote-id:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
.bodytext {
|
||||
line-height: 21px;
|
||||
font-size: smaller;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
table, td, input.text, textarea, select {
|
||||
background-color: #01002c11;
|
||||
color: #ffffff;
|
||||
border-color: #ffa500;
|
||||
}
|
||||
|
||||
input.button {
|
||||
background-color: #ffa500;
|
||||
color: #00000000;
|
||||
}
|
||||
|
||||
input.button:hover {
|
||||
background-color: #ff8c00;
|
||||
}
|
||||
|
||||
.footertext, .toplinks, h2 {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
td.quote-box {
|
||||
border-color: #ffa500;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #ffa500;
|
||||
color: #121212;
|
||||
}
|
||||
|
||||
#pagination a {
|
||||
border-color: #ffa500;
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
#pagination a:hover {
|
||||
background-color: #333333;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.quote-id {
|
||||
color: #ffa500;
|
||||
}
|
||||
}
|
||||
189
static/modapp.js
Normal file
189
static/modapp.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* ModApp JavaScript - AJAX moderation actions without page refresh
|
||||
*/
|
||||
|
||||
// Handle individual moderation actions (approve, reject, delete, clear_flags)
|
||||
async function moderationAction(action, quoteId, element) {
|
||||
try {
|
||||
// Show loading state
|
||||
const originalText = element.textContent;
|
||||
element.textContent = 'Loading...';
|
||||
element.style.pointerEvents = 'none';
|
||||
|
||||
const response = await fetch(`/${action}/${quoteId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest' // Tell server this is AJAX
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Show success message briefly
|
||||
showMessage(result.message, 'success');
|
||||
|
||||
// Remove the quote from the current view or update its display
|
||||
const quoteRow = element.closest('tr');
|
||||
if (quoteRow) {
|
||||
// Fade out the quote
|
||||
quoteRow.style.transition = 'opacity 0.3s ease';
|
||||
quoteRow.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
quoteRow.remove();
|
||||
updateCounters();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
// Show error message
|
||||
showMessage(result.message || 'Action failed', 'error');
|
||||
// Restore original state
|
||||
element.textContent = originalText;
|
||||
element.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Moderation action failed:', error);
|
||||
showMessage('Network error. Please try again.', 'error');
|
||||
// Restore original state
|
||||
element.textContent = originalText;
|
||||
element.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
return false; // Prevent default link behavior
|
||||
}
|
||||
|
||||
// Show temporary message to user
|
||||
function showMessage(message, type = 'info') {
|
||||
// Remove any existing messages
|
||||
const existingMsg = document.getElementById('temp-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
|
||||
// Create new message element
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.id = 'temp-message';
|
||||
msgDiv.textContent = message;
|
||||
msgDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
transition: opacity 0.3s ease;
|
||||
${type === 'success' ? 'background-color: #28a745;' : ''}
|
||||
${type === 'error' ? 'background-color: #dc3545;' : ''}
|
||||
${type === 'info' ? 'background-color: #17a2b8;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(msgDiv);
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
msgDiv.style.opacity = '0';
|
||||
setTimeout(() => msgDiv.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Update quote counters (simplified - could be enhanced with actual counts)
|
||||
function updateCounters() {
|
||||
// This could be enhanced to fetch actual counts from server
|
||||
// For now, just indicate that counts may have changed
|
||||
const statusElements = document.querySelectorAll('.quote-status');
|
||||
statusElements.forEach(el => {
|
||||
el.style.opacity = '0.8';
|
||||
setTimeout(() => el.style.opacity = '1', 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle bulk actions form
|
||||
async function handleBulkAction(form, event) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
const action = formData.get('action');
|
||||
const quoteIds = formData.getAll('quote_ids');
|
||||
|
||||
if (quoteIds.length === 0) {
|
||||
showMessage('Please select at least one quote', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to ${action} ${quoteIds.length} quote(s)?`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/modapp/bulk', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showMessage(result.message, 'success');
|
||||
|
||||
// Remove processed quotes from view
|
||||
quoteIds.forEach(quoteId => {
|
||||
const checkbox = document.querySelector(`input[value="${quoteId}"]`);
|
||||
if (checkbox) {
|
||||
const quoteRow = checkbox.closest('tr');
|
||||
if (quoteRow) {
|
||||
quoteRow.style.transition = 'opacity 0.3s ease';
|
||||
quoteRow.style.opacity = '0';
|
||||
setTimeout(() => quoteRow.remove(), 300);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
updateCounters();
|
||||
|
||||
} else {
|
||||
showMessage(result.message || 'Bulk action failed', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Bulk action failed:', error);
|
||||
showMessage('Network error. Please try again.', 'error');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Convert all moderation links to AJAX
|
||||
document.querySelectorAll('a[href^="/approve/"], a[href^="/reject/"], a[href^="/delete/"], a[href^="/clear_flags/"]').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const href = this.getAttribute('href');
|
||||
const parts = href.split('/');
|
||||
const action = parts[1]; // approve, reject, delete, clear_flags
|
||||
const quoteId = parts[2];
|
||||
|
||||
moderationAction(action, quoteId, this);
|
||||
});
|
||||
});
|
||||
|
||||
// Convert bulk form to AJAX
|
||||
const bulkForm = document.querySelector('form[action="/modapp/bulk"]');
|
||||
if (bulkForm) {
|
||||
bulkForm.addEventListener('submit', function(e) {
|
||||
handleBulkAction(this, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
563
static/styles.css
Normal file
563
static/styles.css
Normal file
@@ -0,0 +1,563 @@
|
||||
/* Global Styles */
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: #c08000;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
a:visited, a:link, a:hover {
|
||||
color: #c08000;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Table and Layout */
|
||||
table {
|
||||
width: 80%;
|
||||
margin: 20px auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
h2 {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
color: #c08000;
|
||||
}
|
||||
|
||||
/* Text Classes */
|
||||
.smalltext {
|
||||
font-size: 10px;
|
||||
background-color: #f0f0f0;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.bodytext {
|
||||
line-height: 21px;
|
||||
font-size: smaller;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.footertext {
|
||||
font-size: smaller;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toplinks {
|
||||
font-size: smaller;
|
||||
color: #000000;
|
||||
text-align: right;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.topnum {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Input Fields */
|
||||
input.text, textarea, select {
|
||||
color: #000000;
|
||||
background-color: #ffffff;
|
||||
padding: 5px;
|
||||
border: 1px solid #c08000;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
input.button {
|
||||
background-color: #c08000;
|
||||
color: #000000;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
input.button:hover {
|
||||
background-color: #a06500;
|
||||
}
|
||||
|
||||
/* Quote Styling */
|
||||
.qt {
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
font-size: 10pt;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.qa {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 10pt;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #808080;
|
||||
padding: 1px 4px;
|
||||
margin: 0 1px;
|
||||
color: #000000;
|
||||
display: inline-block;
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.qa:hover {
|
||||
background-color: #e0e0e0;
|
||||
border-color: #606060;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.qa:active {
|
||||
background-color: #d0d0d0;
|
||||
border: 1px inset #808080;
|
||||
}
|
||||
|
||||
/* Quote controls wrapper for better mobile layout */
|
||||
.quote-controls {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.quote {
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
font-size: smaller;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Borders for Quotes and Other Content */
|
||||
td.quote-box {
|
||||
border: 1px solid #c08000;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: smaller;
|
||||
background-color: #c08000;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
#pagination {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
#pagination a {
|
||||
color: #c08000;
|
||||
text-decoration: none;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #c08000;
|
||||
margin: 0 5px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
#pagination a:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Quote List */
|
||||
.quote-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.quote-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.quote-id {
|
||||
color: #c08000;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
padding: 2px 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.quote-id:hover {
|
||||
text-decoration: underline;
|
||||
background-color: rgba(192, 128, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Dark Mode Toggle Button */
|
||||
#theme-toggle {
|
||||
background-color: #f0f0f0;
|
||||
border: 2px outset #c0c0c0;
|
||||
color: #000000;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#theme-toggle:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
#theme-toggle:active {
|
||||
border: 2px inset #c0c0c0;
|
||||
}
|
||||
|
||||
/* Dark Mode Styles */
|
||||
body.dark-theme {
|
||||
background-color: #121212;
|
||||
color: #d1d1d1;
|
||||
}
|
||||
|
||||
body.dark-theme a {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
body.dark-theme a:hover {
|
||||
color: #ffcc80;
|
||||
}
|
||||
|
||||
body.dark-theme .bodytext {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-theme .qt {
|
||||
color: #ffffff;
|
||||
background-color: #1e1e1e;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #ffa500;
|
||||
}
|
||||
|
||||
body.dark-theme .qa {
|
||||
background-color: #2e2e2e;
|
||||
color: #ffffff;
|
||||
border: 1px solid #ffa500;
|
||||
}
|
||||
|
||||
body.dark-theme .qa:hover {
|
||||
background-color: #3e3e3e;
|
||||
color: #ffcc80;
|
||||
}
|
||||
|
||||
body.dark-theme table {
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #ffa500;
|
||||
}
|
||||
|
||||
body.dark-theme td[bgcolor="#c08000"] {
|
||||
background-color: #c08000 !important;
|
||||
}
|
||||
|
||||
body.dark-theme td[bgcolor="#f0f0f0"] {
|
||||
background-color: #2e2e2e !important;
|
||||
color: #d1d1d1;
|
||||
}
|
||||
|
||||
body.dark-theme #theme-toggle {
|
||||
background-color: #2e2e2e;
|
||||
border: 2px outset #555555;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark-theme #theme-toggle:hover {
|
||||
background-color: #3e3e3e;
|
||||
}
|
||||
|
||||
body.dark-theme #theme-toggle:active {
|
||||
border: 2px inset #555555;
|
||||
}
|
||||
|
||||
body.dark-theme input[type="text"],
|
||||
body.dark-theme input[type="number"],
|
||||
body.dark-theme textarea,
|
||||
body.dark-theme select {
|
||||
background-color: #2e2e2e;
|
||||
color: #ffffff;
|
||||
border: 1px solid #555555;
|
||||
}
|
||||
|
||||
body.dark-theme input[type="submit"],
|
||||
body.dark-theme button {
|
||||
background-color: #2e2e2e;
|
||||
color: #ffffff;
|
||||
border: 2px outset #555555;
|
||||
}
|
||||
|
||||
body.dark-theme input[type="submit"]:hover,
|
||||
body.dark-theme button:hover {
|
||||
background-color: #3e3e3e;
|
||||
}
|
||||
|
||||
/* Override inline font colors in dark mode */
|
||||
body.dark-theme font[color="green"] {
|
||||
color: #90ee90 !important;
|
||||
}
|
||||
|
||||
body.dark-theme font {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/* Apply dark theme when class is on html element (prevents flash) */
|
||||
html.dark-theme body {
|
||||
background-color: #121212;
|
||||
color: #d1d1d1;
|
||||
}
|
||||
|
||||
html.dark-theme a {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
html.dark-theme a:hover {
|
||||
color: #ffcc80;
|
||||
}
|
||||
|
||||
html.dark-theme .bodytext {
|
||||
color: #d1d1d1;
|
||||
}
|
||||
|
||||
html.dark-theme .qt {
|
||||
background-color: #2e2e2e;
|
||||
color: #ffffff;
|
||||
border-left: 3px solid #ffa500;
|
||||
}
|
||||
|
||||
html.dark-theme .qa {
|
||||
background-color: #333;
|
||||
color: #d1d1d1;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
html.dark-theme .qa:hover {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html.dark-theme table {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
html.dark-theme td[bgcolor="#c08000"] {
|
||||
background-color: #8b4513 !important;
|
||||
}
|
||||
|
||||
html.dark-theme td[bgcolor="#f0f0f0"] {
|
||||
background-color: #333 !important;
|
||||
}
|
||||
|
||||
html.dark-theme #theme-toggle {
|
||||
background-color: #333;
|
||||
color: #d1d1d1;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
html.dark-theme #theme-toggle:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
html.dark-theme #theme-toggle:active {
|
||||
background-color: #3e3e3e;
|
||||
}
|
||||
|
||||
html.dark-theme input[type="text"],
|
||||
html.dark-theme input[type="number"],
|
||||
html.dark-theme textarea,
|
||||
html.dark-theme select {
|
||||
background-color: #333;
|
||||
color: #d1d1d1;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
html.dark-theme input[type="submit"],
|
||||
html.dark-theme button {
|
||||
background-color: #444;
|
||||
color: #d1d1d1;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
html.dark-theme input[type="submit"]:hover,
|
||||
html.dark-theme button:hover {
|
||||
background-color: #3e3e3e;
|
||||
}
|
||||
|
||||
/* Override inline font colors in dark mode (for html element) */
|
||||
html.dark-theme font[color="green"] {
|
||||
color: #90ee90 !important;
|
||||
}
|
||||
|
||||
html.dark-theme font {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/* Mobile Responsive Design */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* Make tables and content mobile-friendly while keeping bash.org aesthetic */
|
||||
table {
|
||||
width: 95%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Navigation links - stack on small screens */
|
||||
.toplinks {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
/* Quote buttons - compact but touch-friendly */
|
||||
.qa {
|
||||
padding: 6px 8px;
|
||||
margin: 1px 2px;
|
||||
min-width: 28px;
|
||||
min-height: 32px;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Quote text - ensure readability */
|
||||
.qt {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Quote header - adjust spacing and make more compact */
|
||||
.quote {
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Form elements - make touch-friendly */
|
||||
input[type="text"], input[type="number"], textarea, select {
|
||||
width: 90%;
|
||||
padding: 8px;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
input[type="submit"], button {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
margin: 3px;
|
||||
min-height: 40px; /* Reasonable touch target */
|
||||
}
|
||||
|
||||
/* Pagination - mobile friendly */
|
||||
#pagination {
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#pagination a {
|
||||
padding: 6px 10px;
|
||||
margin: 2px;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ModApp table - horizontal scroll for wide tables */
|
||||
.modapp-table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.modapp-table-container table {
|
||||
min-width: 600px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Compact view for smaller screens */
|
||||
@media screen and (max-width: 480px) {
|
||||
.mobile-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.qa {
|
||||
padding: 5px 7px;
|
||||
font-size: 13px;
|
||||
min-width: 26px;
|
||||
min-height: 30px;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.quote {
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.qt {
|
||||
font-size: 13px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
/* Stack quote controls for very small screens */
|
||||
.quote-controls {
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly improvements for all screen sizes */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* This targets touch devices */
|
||||
.qa {
|
||||
padding: 7px 10px;
|
||||
margin: 2px;
|
||||
min-height: 36px;
|
||||
min-width: 32px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 3px;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
/* Ensure all interactive elements are large enough but not oversized */
|
||||
button, input[type="submit"], input[type="button"] {
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Theme toggle button - keep compact */
|
||||
#theme-toggle {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
44
static/theme.js
Normal file
44
static/theme.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Dark mode toggle functionality for ircquotes
|
||||
// Maintains bash.org aesthetic while providing dark theme option
|
||||
|
||||
function toggleDarkMode() {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const isDark = body.classList.contains('dark-theme') || html.classList.contains('dark-theme');
|
||||
|
||||
if (isDark) {
|
||||
body.classList.remove('dark-theme');
|
||||
html.classList.remove('dark-theme');
|
||||
localStorage.setItem('theme', 'light');
|
||||
updateToggleButton(false);
|
||||
} else {
|
||||
body.classList.add('dark-theme');
|
||||
html.classList.add('dark-theme');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
updateToggleButton(true);
|
||||
}
|
||||
}
|
||||
|
||||
function updateToggleButton(isDark) {
|
||||
const toggleBtn = document.getElementById('theme-toggle');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.textContent = isDark ? '☀' : '🌙';
|
||||
toggleBtn.title = isDark ? 'Switch to light mode' : 'Switch to dark mode';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.body.classList.add('dark-theme');
|
||||
document.documentElement.classList.add('dark-theme');
|
||||
updateToggleButton(true);
|
||||
} else {
|
||||
document.body.classList.remove('dark-theme');
|
||||
document.documentElement.classList.remove('dark-theme');
|
||||
updateToggleButton(false);
|
||||
}
|
||||
});
|
||||
232
static/voting.js
Normal file
232
static/voting.js
Normal file
@@ -0,0 +1,232 @@
|
||||
// AJAX voting functionality
|
||||
function vote(quoteId, action, buttonElement) {
|
||||
// Prevent multiple clicks on the same button
|
||||
if (buttonElement.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Disable button temporarily to prevent double-clicks
|
||||
buttonElement.disabled = true;
|
||||
|
||||
// Make AJAX request
|
||||
fetch(`/vote/${quoteId}/${action}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
// Handle both successful and error responses with JSON
|
||||
return response.json().then(data => {
|
||||
return { data, status: response.status, ok: response.ok };
|
||||
});
|
||||
})
|
||||
.then(result => {
|
||||
const { data, status, ok } = result;
|
||||
|
||||
if (ok && data.success) {
|
||||
// Update vote count display
|
||||
const voteElement = document.getElementById(`votes-${quoteId}`);
|
||||
if (voteElement) {
|
||||
voteElement.textContent = data.votes;
|
||||
}
|
||||
|
||||
// Update button states based on user's voting history
|
||||
updateButtonStates(quoteId, data.user_vote);
|
||||
} else {
|
||||
// Show the server's error message, with special handling for rate limiting
|
||||
let errorMessage = data.message || 'Sorry, your vote could not be recorded. Please try again.';
|
||||
|
||||
if (status === 429) {
|
||||
// Rate limiting or flood control
|
||||
errorMessage = data.message || 'Please slow down! You\'re voting too quickly. Wait a moment and try again.';
|
||||
}
|
||||
|
||||
alert(errorMessage);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Connection error while voting. Please check your internet connection and try again.');
|
||||
})
|
||||
.finally(() => {
|
||||
// Re-enable the button immediately
|
||||
buttonElement.disabled = false;
|
||||
});
|
||||
|
||||
return false; // Prevent default link behavior
|
||||
}
|
||||
|
||||
function updateButtonStates(quoteId, userVote) {
|
||||
const upButton = document.getElementById(`up-${quoteId}`);
|
||||
const downButton = document.getElementById(`down-${quoteId}`);
|
||||
|
||||
if (upButton && downButton) {
|
||||
// Reset button styles
|
||||
upButton.style.backgroundColor = '';
|
||||
downButton.style.backgroundColor = '';
|
||||
|
||||
// Highlight the voted button
|
||||
if (userVote === 'upvote') {
|
||||
upButton.style.backgroundColor = '#90EE90'; // Light green
|
||||
} else if (userVote === 'downvote') {
|
||||
downButton.style.backgroundColor = '#FFB6C1'; // Light pink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag quote functionality
|
||||
function flag(quoteId, buttonElement) {
|
||||
if (buttonElement.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to flag this quote as inappropriate?')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
buttonElement.disabled = true;
|
||||
|
||||
fetch(`/flag/${quoteId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message || 'Thank you! This quote has been flagged for review by moderators.');
|
||||
buttonElement.style.backgroundColor = '#FFB6C1'; // Light pink
|
||||
buttonElement.textContent = '✓';
|
||||
} else {
|
||||
alert(data.message || 'Sorry, we could not flag this quote. Please try again.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Connection error while flagging. Please check your internet connection and try again.');
|
||||
})
|
||||
.finally(() => {
|
||||
buttonElement.disabled = false;
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy quote functionality
|
||||
function copyQuote(quoteId, buttonElement) {
|
||||
if (buttonElement.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the quote text
|
||||
const quoteElement = document.querySelector(`#quote-${quoteId} .qt, [data-quote-id="${quoteId}"] .qt`);
|
||||
let quoteText = '';
|
||||
|
||||
if (quoteElement) {
|
||||
quoteText = quoteElement.textContent || quoteElement.innerText;
|
||||
} else {
|
||||
// Fallback: look for quote text in any element after the quote header
|
||||
const allQuotes = document.querySelectorAll('.qt');
|
||||
const quoteHeaders = document.querySelectorAll('.quote');
|
||||
|
||||
for (let i = 0; i < quoteHeaders.length; i++) {
|
||||
const header = quoteHeaders[i];
|
||||
if (header.innerHTML.includes(`#${quoteId}`)) {
|
||||
if (allQuotes[i]) {
|
||||
quoteText = allQuotes[i].textContent || allQuotes[i].innerText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!quoteText) {
|
||||
alert('Sorry, we could not find the quote text to copy. Please try selecting and copying the text manually.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Format the text with quote number
|
||||
const formattedText = `#${quoteId}: ${quoteText.trim()}`;
|
||||
|
||||
// Copy to clipboard
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
// Modern approach
|
||||
navigator.clipboard.writeText(formattedText).then(() => {
|
||||
showCopySuccess(buttonElement);
|
||||
}).catch(() => {
|
||||
fallbackCopy(formattedText, buttonElement);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
fallbackCopy(formattedText, buttonElement);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function fallbackCopy(text, buttonElement) {
|
||||
// Create temporary textarea
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopySuccess(buttonElement);
|
||||
} catch (err) {
|
||||
console.error('Could not copy text: ', err);
|
||||
alert('Copy to clipboard failed. Please manually select and copy the quote text using Ctrl+C (or Cmd+C on Mac).');
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function showCopySuccess(buttonElement) {
|
||||
const originalText = buttonElement.textContent;
|
||||
buttonElement.textContent = '✓';
|
||||
buttonElement.style.backgroundColor = '#90EE90'; // Light green
|
||||
|
||||
setTimeout(() => {
|
||||
buttonElement.textContent = originalText;
|
||||
buttonElement.style.backgroundColor = '';
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Load user vote states when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get all vote elements and check their states
|
||||
const voteElements = document.querySelectorAll('[id^="votes-"]');
|
||||
|
||||
// Get user's voting history from cookies
|
||||
const votes = getCookie('votes');
|
||||
if (votes) {
|
||||
try {
|
||||
const voteData = JSON.parse(votes);
|
||||
|
||||
// Update button states for each quote
|
||||
voteElements.forEach(element => {
|
||||
const quoteId = element.id.replace('votes-', '');
|
||||
const userVote = voteData[quoteId];
|
||||
if (userVote) {
|
||||
updateButtonStates(quoteId, userVote);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('Could not parse vote cookie');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to get cookie value
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
}
|
||||
@@ -5,7 +5,19 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Browse Quotes</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='voting.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
@@ -18,7 +30,7 @@
|
||||
<font size="+1"><b><i>ircquotes</i></b></font>
|
||||
</td>
|
||||
<td bgcolor="#c08000" align="right">
|
||||
<font face="arial" size="+1"><b>Browse Quotes</b></font>
|
||||
<font face="arial" size="+1"><b>{% if is_top %}Top Quotes{% else %}Browse Quotes{% endif %}</b></font>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -33,8 +45,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -48,12 +61,24 @@
|
||||
{% for quote in quotes.items %}
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
||||
<a href="/vote/{{ quote.id }}/upvote?page={{ quotes.page }}" class="qa">+</a>
|
||||
(<font color="green">{{ quote.votes }}</font>)
|
||||
<a href="/vote/{{ quote.id }}/downvote?page={{ quotes.page }}" class="qa">-</a>
|
||||
<a href="/flag/{{ quote.id }}" class="qa"></a>
|
||||
|
||||
<a href="#" onclick="return vote({{ quote.id }}, "upvote", this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||
<a href="#" onclick="return vote({{ quote.id }}, "downvote", this)" class="qa" id="down-{{ quote.id }}">-</a>
|
||||
|
||||
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
|
||||
|
||||
<a href="#" onclick="return copyQuote({{ quote.id }}, this)" class="qa" title="Copy quote to clipboard">C</a>
|
||||
|
||||
<span style="color: #666; font-size: 0.9em;">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text }}</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
</td>
|
||||
@@ -94,7 +119,7 @@
|
||||
|
||||
<font size="-1">
|
||||
<a href="#">Hosted by YourHostingProvider</a><br>
|
||||
© ircquotes 2024, All Rights Reserved.
|
||||
@ ircquotes 2024-2025
|
||||
</font>
|
||||
</center>
|
||||
|
||||
|
||||
@@ -4,7 +4,18 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: FAQ</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
|
||||
@@ -29,8 +40,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -44,37 +56,24 @@
|
||||
<h2>Frequently Asked Questions (FAQ)</h2>
|
||||
|
||||
<h3>What is ircquotes?</h3>
|
||||
<p>ircquotes is a community-driven website where users can submit and browse memorable quotes from IRC (Internet Relay Chat). You can browse quotes, submit your own, and vote on others.</p>
|
||||
<p>ircquotes is a community-driven website where users can submit and browse memorable quotes from IRC (Internet Relay Chat) and other chat platforms. You can browse quotes, submit your own, and vote on others.</p>
|
||||
|
||||
<h3>How does the API work?</h3>
|
||||
<p>The ircquotes API allows users to retrieve quotes programmatically. It is designed for developers who want to integrate IRC quotes into their own applications.</p>
|
||||
<p>The ircquotes API is a read-only interface that allows users to retrieve quotes programmatically. It is designed for developers who want to integrate IRC quotes into their own applications. Quote submissions must be done through the web interface to prevent abuse.</p>
|
||||
|
||||
<h4>Available API Endpoints</h4>
|
||||
<ul>
|
||||
<li><strong>Get All Approved Quotes</strong>: <code>GET /api/quotes</code></li>
|
||||
<li><strong>Get a Specific Quote by ID</strong>: <code>GET /api/quotes/<id></code></li>
|
||||
<li><strong>Get a Random Quote</strong>: <code>GET /api/random</code></li>
|
||||
<li><strong>Get Top Quotes</strong>: <code>GET /api/top</code></li>
|
||||
<li><strong>Search Quotes</strong>: <code>GET /api/search?q=<search_term></code></li>
|
||||
</ul>
|
||||
|
||||
<h4>Submitting Quotes via the API</h4>
|
||||
<p>The API also allows you to submit quotes, but this feature is rate-limited to prevent abuse. Each user is allowed 5 submissions per minute.</p>
|
||||
<ul>
|
||||
<li><strong>Submit a Quote</strong>: <code>POST /api/submit</code></li>
|
||||
<li><strong>Request Body</strong>: The request body should be in JSON format and contain the quote text like this:</li>
|
||||
<pre><code>{
|
||||
"text": "This is a memorable quote!"
|
||||
}</code></pre>
|
||||
<li><strong>Validation Rules</strong>: Quotes must be between 5 and 1000 characters.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Rules for Submitting Quotes</h3>
|
||||
<p>To ensure that ircquotes remains a fun and enjoyable platform for everyone, we ask that you follow a few simple rules when submitting quotes:</p>
|
||||
<ul>
|
||||
<li><strong>No Offensive Content</strong>: Do not submit quotes that contain offensive language, hate speech, or other harmful content.</li>
|
||||
<li><strong>No Spam</strong>: Please avoid submitting irrelevant or repetitive content. The site is for memorable IRC quotes, not spam.</li>
|
||||
<li><strong>Stay On-Topic</strong>: Ensure that your submissions are actual quotes from IRC, not made-up content.</li>
|
||||
<li><strong>Stay On-Topic</strong>: Ensure that your submissions are actual quotes from chat platforms, not made-up content.</li>
|
||||
<li><strong>Rate Limiting</strong>: The submission rate is limited to 5 quotes per minute to prevent spam.</li>
|
||||
<li><strong>Moderation</strong>: All quotes are subject to approval by site moderators. Rejected quotes will not be publicly visible.</li>
|
||||
</ul>
|
||||
|
||||
@@ -5,7 +5,18 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Home</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
@@ -33,8 +44,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -56,7 +68,8 @@
|
||||
|
||||
<td class="bodytext" width="50%" valign="top">
|
||||
<b>Latest Updates</b>
|
||||
<p><strong>13/10/24</strong><br>Added dark theme and re added all of bash.org's old quotes.</p>
|
||||
<p><strong>20/09/25</strong><br>Improved some things in the backend and added Dark theme</p>
|
||||
<p><strong>13/10/24</strong><br>Re added all of bash.org's old quotes.</p>
|
||||
<p><strong>09/10/24</strong><br>We are now live! Start submitting your favourite IRC quotes.</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -72,7 +85,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<font size="-1"><a href="#"></a><br>© ircquotes 2024, All Rights Reserved.</font>
|
||||
<font size="-1"><a href="#"></a><br>@ ircquotes 2024-2025</font>
|
||||
</center>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -4,7 +4,18 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Admin Login</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
|
||||
@@ -31,8 +42,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -42,6 +54,7 @@
|
||||
<center>
|
||||
<h2>Admin Login</h2>
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Username:</td>
|
||||
@@ -80,7 +93,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<font size="-1">© ircquotes 2024, All Rights Reserved.</font>
|
||||
<font size="-1">@ ircquotes 2024-2025</font>
|
||||
</center>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Admin Panel</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modapp.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
|
||||
@@ -29,6 +41,7 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">Modapp</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -45,48 +58,138 @@
|
||||
<option value="pending" {% if filter_status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="approved" {% if filter_status == 'approved' %}selected{% endif %}>Approved</option>
|
||||
<option value="rejected" {% if filter_status == 'rejected' %}selected{% endif %}>Rejected</option>
|
||||
<option value="flagged" {% if filter_status == 'flagged' %}selected{% endif %}>Flagged</option>
|
||||
</select>
|
||||
<input type="submit" value="Apply Filter">
|
||||
</form>
|
||||
|
||||
{% if quotes.items %}
|
||||
<!-- Bulk Actions Form -->
|
||||
<form id="bulk-action-form" method="POST" action="/modapp/bulk">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div style="margin: 10px 0; padding: 10px; background-color: #f0f0f0; border: 1px solid #ccc;">
|
||||
<b>Bulk Actions:</b>
|
||||
<input type="checkbox" id="select-all" onchange="toggleAllCheckboxes(this)"> <label for="select-all">Select All</label>
|
||||
|
||||
<button type="submit" name="action" value="approve" class="qa" onclick="return confirmBulkAction('approve')">Bulk Approve</button>
|
||||
<button type="submit" name="action" value="reject" class="qa" onclick="return confirmBulkAction('reject')">Bulk Reject</button>
|
||||
<button type="submit" name="action" value="delete" class="qa" onclick="return confirmBulkAction('delete')">Bulk Delete</button>
|
||||
<button type="submit" name="action" value="clear_flags" class="qa" onclick="return confirmBulkAction('clear flags')">Clear All Flags</button>
|
||||
</div>
|
||||
|
||||
<!-- Table for Quotes -->
|
||||
<div class="modapp-table-container">
|
||||
<table border="1" cellpadding="5" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<th>Select</th>
|
||||
<th>Quote ID</th>
|
||||
<th>Quote</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted At</th>
|
||||
<th>IP Address</th>
|
||||
<th>User Agent</th>
|
||||
<th>Flags</th>
|
||||
<th class="mobile-hide">Submitted At</th>
|
||||
<th class="mobile-hide">IP Address</th>
|
||||
<th class="mobile-hide">User Agent</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
<!-- Loop through quotes -->
|
||||
{% for quote in quotes.items %}
|
||||
<tr style="background-color:
|
||||
{% if quote.status == 1 %} #d4edda {% elif quote.status == 2 %} #f8d7da {% else %} #fff {% endif %}">
|
||||
{% if quote.flag_count > 5 %} #ffcccc {% elif quote.flag_count > 2 %} #ffe6cc {% elif quote.status == 1 %} #d4edda {% elif quote.status == 2 %} #f8d7da {% else %} #fff {% endif %}">
|
||||
<td><input type="checkbox" name="quote_ids" value="{{ quote.id }}" class="quote-checkbox"></td>
|
||||
<td>#{{ quote.id }}</td>
|
||||
<td>{{ quote.text }}</td>
|
||||
<td>{{ quote.text|e }}</td>
|
||||
<td>
|
||||
{% if quote.status == 0 %}
|
||||
Pending
|
||||
{% elif quote.status == 1 %}
|
||||
Approved
|
||||
{% if filter_status == 'flagged' %}
|
||||
<!-- Prominent status display for flagged quotes -->
|
||||
{% if quote.status == 0 %}
|
||||
<span style="background-color: #fff3cd; padding: 2px 6px; border-radius: 3px; font-weight: bold;">⚠️ PENDING + FLAGGED</span>
|
||||
{% elif quote.status == 1 %}
|
||||
<span style="background-color: #d4edda; padding: 2px 6px; border-radius: 3px; font-weight: bold;">✅ APPROVED + FLAGGED</span>
|
||||
{% else %}
|
||||
<span style="background-color: #f8d7da; padding: 2px 6px; border-radius: 3px; font-weight: bold;">❌ REJECTED + FLAGGED</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Rejected
|
||||
<!-- Normal status display -->
|
||||
{% if quote.status == 0 %}
|
||||
Pending
|
||||
{% elif quote.status == 1 %}
|
||||
Approved
|
||||
{% else %}
|
||||
Rejected
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ quote.submitted_at.strftime('%Y-%m-%d %H:%M:%S') if quote.submitted_at else 'N/A' }}</td>
|
||||
<td>{{ quote.ip_address }}</td>
|
||||
<td>{{ quote.user_agent }}</td>
|
||||
<td>
|
||||
<a href="/approve/{{ quote.id }}">Approve</a> |
|
||||
<a href="/reject/{{ quote.id }}">Reject</a> |
|
||||
<a href="/delete/{{ quote.id }}">Delete</a>
|
||||
{% if quote.flag_count > 0 %}
|
||||
<span style="color: red; font-weight: bold;">{{ quote.flag_count }}</span>
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mobile-hide">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M:%S') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }} (legacy)
|
||||
{% else %}
|
||||
No date
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mobile-hide">{{ quote.ip_address|e }}</td>
|
||||
<td class="mobile-hide">{{ quote.user_agent|e|truncate(50) }}</td>
|
||||
<td>
|
||||
{% if filter_status == 'flagged' %}
|
||||
<!-- Special actions for flagged quotes -->
|
||||
{% if quote.status == 1 %}
|
||||
<!-- Already approved but flagged -->
|
||||
<a href="/clear_flags/{{ quote.id }}" style="color: blue;">Clear Flags</a> |
|
||||
<a href="/reject/{{ quote.id }}" style="color: orange;">Reject</a> |
|
||||
<a href="/delete/{{ quote.id }}" style="color: red;">Delete</a>
|
||||
{% elif quote.status == 0 %}
|
||||
<!-- Pending and flagged -->
|
||||
<a href="/approve/{{ quote.id }}" style="color: green;">Approve</a> |
|
||||
<a href="/clear_flags/{{ quote.id }}" style="color: blue;">Clear Flags</a> |
|
||||
<a href="/reject/{{ quote.id }}" style="color: orange;">Reject</a> |
|
||||
<a href="/delete/{{ quote.id }}" style="color: red;">Delete</a>
|
||||
{% else %}
|
||||
<!-- Rejected and flagged -->
|
||||
<a href="/clear_flags/{{ quote.id }}" style="color: blue;">Clear Flags</a> |
|
||||
<a href="/delete/{{ quote.id }}" style="color: red;">Delete</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<!-- Standard actions for non-flagged quotes -->
|
||||
<a href="/approve/{{ quote.id }}">Approve</a> |
|
||||
<a href="/reject/{{ quote.id }}">Reject</a> |
|
||||
<a href="/delete/{{ quote.id }}">Delete</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Bulk Actions JavaScript -->
|
||||
<script>
|
||||
function toggleAllCheckboxes(selectAllCheckbox) {
|
||||
const checkboxes = document.querySelectorAll('.quote-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = selectAllCheckbox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
function confirmBulkAction(action) {
|
||||
const selectedCheckboxes = document.querySelectorAll('.quote-checkbox:checked');
|
||||
if (selectedCheckboxes.length === 0) {
|
||||
alert('Please select at least one quote.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const count = selectedCheckboxes.length;
|
||||
const message = `Are you sure you want to ${action} ${count} selected quote(s)?`;
|
||||
return confirm(message);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Pagination Links -->
|
||||
<div id="pagination">
|
||||
@@ -118,7 +221,8 @@
|
||||
<tr>
|
||||
<td class="footertext" align="left"> </td>
|
||||
<td class="footertext" align="right">
|
||||
{{ approved_count }} quotes approved; {{ pending_count }} quotes pending; {{ rejected_count }} quotes rejected
|
||||
{{ approved_count }} quotes approved; {{ pending_count }} quotes pending; {{ rejected_count }} quotes rejected;
|
||||
<span style="color: red; font-weight: bold;">{{ flagged_count }} quotes flagged</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -5,7 +5,19 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Quote #{{ quote.id }}</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='voting.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
@@ -33,8 +45,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -47,13 +60,25 @@
|
||||
<td valign="top">
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
||||
<a href="/vote/{{ quote.id }}/upvote" class="qa">+</a>
|
||||
(<font color="green">{{ quote.votes }}</font>)
|
||||
<a href="/vote/{{ quote.id }}/downvote" class="qa">-</a>
|
||||
<a href="/flag/{{ quote.id }}" class="qa">[X]</a>
|
||||
|
||||
<a href="#" onclick="return vote({{ quote.id }}, 'upvote', this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||
<a href="#" onclick="return vote({{ quote.id }}, 'downvote', this)" class="qa" id="down-{{ quote.id }}">-</a>
|
||||
|
||||
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
|
||||
|
||||
<a href="#" onclick="return copyQuote({{ quote.id }}, this)" class="qa" title="Copy quote to clipboard">C</a>
|
||||
|
||||
<span style="color: #666; font-size: 0.9em;">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p class="qt">{{ quote.text }}</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
</td>
|
||||
<td valign="top"></td>
|
||||
</tr>
|
||||
@@ -78,8 +103,7 @@
|
||||
</table>
|
||||
|
||||
<font size="-1">
|
||||
<a href="#">Hosted by YourHostingProvider</a><br>
|
||||
© ircquotes 2024, All Rights Reserved.
|
||||
@ ircquotes 2024-2025
|
||||
</font>
|
||||
</center>
|
||||
|
||||
|
||||
@@ -5,7 +5,19 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Random Quote</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='voting.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
@@ -30,8 +42,9 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -43,12 +56,24 @@
|
||||
<td valign="top">
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
||||
<a href="/vote/{{ quote.id }}/upvote" class="qa">+</a>
|
||||
(<font color="green">{{ quote.votes }}</font>)
|
||||
<a href="/vote/{{ quote.id }}/downvote" class="qa">-</a>
|
||||
<a href="/flag/{{ quote.id }}" class="qa">[X]</a>
|
||||
|
||||
<a href="#" onclick="return vote({{ quote.id }}, 'upvote', this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||
<a href="#" onclick="return vote({{ quote.id }}, 'downvote', this)" class="qa" id="down-{{ quote.id }}">-</a>
|
||||
|
||||
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
|
||||
|
||||
<a href="#" onclick="return copyQuote({{ quote.id }}, this)" class="qa" title="Copy quote to clipboard">C</a>
|
||||
|
||||
<span style="color: #666; font-size: 0.9em;">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text }}</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -74,9 +99,10 @@
|
||||
</table>
|
||||
|
||||
<font size="-1">
|
||||
© ircquotes 2024, All Rights Reserved.
|
||||
@ ircquotes 2024-2025
|
||||
</font>
|
||||
</center>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -3,8 +3,20 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Search</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<title>ircquotes: Search & Read</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='voting.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
|
||||
|
||||
@@ -16,7 +28,7 @@
|
||||
<font size="+1"><b><i>ircquotes</i></b></font>
|
||||
</td>
|
||||
<td bgcolor="#c08000" align="right">
|
||||
<font face="arial" size="+1"><b>Search Quotes</b></font>
|
||||
<font face="arial" size="+1"><b>Search & Read Quotes</b></font>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -31,49 +43,77 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
|
||||
<!-- Search Form -->
|
||||
<!-- Search Forms -->
|
||||
<center>
|
||||
<h2>Search for Quotes</h2>
|
||||
<form action="/search" method="GET">
|
||||
<input type="text" name="q" class="text" placeholder="Enter search term" required>
|
||||
<input type="submit" value="Search" class="button">
|
||||
</form>
|
||||
<table cellpadding="0" cellspacing="3" width="80%">
|
||||
<tr>
|
||||
<td class="bodytext" width="100%" valign="top">
|
||||
<!-- Search for Quotes -->
|
||||
<p><b>Search for Quotes by Keyword</b></p>
|
||||
<form action="/search" method="GET">
|
||||
<input type="text" name="q" value="{{ query or '' }}" placeholder="Enter search term" required>
|
||||
<input type="submit" value="Search">
|
||||
</form>
|
||||
<br>
|
||||
|
||||
<!-- Show Search Results only if there is a search query -->
|
||||
{% if query %}
|
||||
<h3>Search Results for "{{ query }}"</h3>
|
||||
|
||||
{% if quotes %}
|
||||
<table cellpadding="0" cellspacing="3" width="80%">
|
||||
<tr>
|
||||
<td class="bodytext" width="100%" valign="top">
|
||||
{% for quote in quotes %}
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote.">
|
||||
<b>#{{ quote.id }}</b>
|
||||
</a>
|
||||
<a href="/vote/{{ quote.id }}/upvote" class="qa">+</a>
|
||||
(<font color="green">{{ quote.votes }}</font>)
|
||||
<a href="/vote/{{ quote.id }}/downvote" class="qa">-</a>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text }}</p>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% else %}
|
||||
<h4>No quotes found for "{{ query }}".</h4>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<!-- Read Quote by Number -->
|
||||
<p><b>Read a Quote by Number</b></p>
|
||||
<form action="/quote" method="GET">
|
||||
<input type="number" name="id" placeholder="Enter quote number" required>
|
||||
<input type="submit" value="Read">
|
||||
</form>
|
||||
<hr>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
<!-- Search Results -->
|
||||
{% if query %}
|
||||
<center>
|
||||
<table cellpadding="0" cellspacing="3" width="80%">
|
||||
<tr>
|
||||
<td class="bodytext" width="100%" valign="top">
|
||||
<p><b>Search Results for "{{ query }}"</b></p>
|
||||
{% if quotes %}
|
||||
{% for quote in quotes %}
|
||||
<p class="quote">
|
||||
<a href="/quote?id={{ quote.id }}" title="Permanent link to this quote."><b>#{{ quote.id }}</b></a>
|
||||
|
||||
<a href="#" onclick="return vote({{ quote.id }}, "upvote", this)" class="qa" id="up-{{ quote.id }}">+</a>
|
||||
<span id="votes-{{ quote.id }}"><font color="green">{{ quote.votes }}</font></span>
|
||||
<a href="#" onclick="return vote({{ quote.id }}, "downvote", this)" class="qa" id="down-{{ quote.id }}">-</a>
|
||||
|
||||
<a href="#" onclick="return flag({{ quote.id }}, this)" class="qa">X</a>
|
||||
|
||||
<a href="#" onclick="return copyQuote({{ quote.id }}, this)" class="qa" title="Copy quote to clipboard">C</a>
|
||||
|
||||
<span style="color: #666; font-size: 0.9em;">
|
||||
{% if quote.submitted_at %}
|
||||
{{ quote.submitted_at.strftime('%d/%m/%y %H:%M') }}
|
||||
{% elif quote.date %}
|
||||
{{ quote.date.strftime('%d/%m/%y') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
<p class="qt">{{ quote.text|e }}</p>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>No quotes found for "{{ query }}".</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
{% endif %}
|
||||
|
||||
<!-- Footer -->
|
||||
<center>
|
||||
@@ -85,17 +125,20 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="footertext" align="left"> </td>
|
||||
<td class="footertext" align="right">{{ approved_count }} quotes approved; {{ pending_count }} quotes pending</td>
|
||||
<td class="footertext" align="right">{{ approved_count }} quotes approved</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<font size="-1">© ircquotes 2024, All Rights Reserved.</font>
|
||||
<font size="-1">
|
||||
<a href="#">Hosted by YourHostingProvider</a><br>
|
||||
@ ircquotes 2024-2025
|
||||
</font>
|
||||
</center>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -4,9 +4,20 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ircquotes: Add a Quote</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
<script>
|
||||
// Prevent flash of white content by applying theme immediately
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.className = 'dark-theme';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='theme.js') }}"></script>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000" onload="document.add.newquote.focus();">
|
||||
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000" onload="document.add.quote.focus();">
|
||||
<center>
|
||||
<!-- Header -->
|
||||
<table cellpadding="2" cellspacing="0" width="80%" border="0">
|
||||
@@ -32,17 +43,31 @@
|
||||
<a href="/submit">Submit</a> /
|
||||
<a href="/browse">Browse</a> /
|
||||
<a href="/modapp">ModApp</a> /
|
||||
<a href="/search">Search</a>
|
||||
<a href="/search">Search</a> /
|
||||
<a href="/faq">FAQ</a>
|
||||
<button id="theme-toggle" onclick="toggleDarkMode()" title="Toggle dark/light mode">🌙</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Add a Quote Form -->
|
||||
<form action="/submit" name="add" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<table cellpadding="2" cellspacing="0" width="60%">
|
||||
{% if preview_text %}
|
||||
<!-- Preview Section -->
|
||||
<tr>
|
||||
<td><textarea cols="100%" rows="10" name="quote" class="text"></textarea></td>
|
||||
<td>
|
||||
<h3>Quote Preview:</h3>
|
||||
<div style="border: 1px solid #ccc; padding: 10px; margin: 10px 0; background-color: #f9f9f9;">
|
||||
<pre style="white-space: pre-wrap; font-family: monospace;">{{ preview_text }}</pre>
|
||||
</div>
|
||||
<p><strong>If this looks correct, click "Submit Quote" below. Otherwise, edit your quote and preview again.</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><textarea cols="100%" rows="10" name="quote" class="text">{{ original_text or '' }}</textarea></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="checkbox" name="strip" checked> Automatically attempt to fix timestamps and common mistakes.</td>
|
||||
@@ -58,7 +83,7 @@
|
||||
<td>
|
||||
<br><br>
|
||||
You may submit quotes from any chat medium, so long as they have reasonable
|
||||
formatting (IRC, AIM, ICQ, Yahoo, NOT MSN or MS Chat).<br><br>
|
||||
formatting (IRC, etc.).<br><br>
|
||||
Tips for approval: no timestamps, keep it short (trim useless parts), remove trailing laughs, and avoid inside jokes.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -78,8 +103,7 @@
|
||||
</table>
|
||||
|
||||
<font size="-1">
|
||||
<a href="#">Hosted by YourHostingProvider</a><br>
|
||||
© ircquotes 2024, All Rights Reserved.
|
||||
@ ircquotes 2024-2025
|
||||
</font>
|
||||
</center>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user