Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e5f27316e | ||
|
|
1a6ad08079 | ||
|
|
1a999ab00b | ||
|
|
72b453d6dd | ||
|
|
ea24102053 | ||
| fecafc15ee |
288
app.py
288
app.py
@@ -432,62 +432,67 @@ def api_posts():
|
|||||||
cutoff_date = datetime.utcnow() - timedelta(days=time_filter_days)
|
cutoff_date = datetime.utcnow() - timedelta(days=time_filter_days)
|
||||||
time_cutoff = cutoff_date.timestamp()
|
time_cutoff = cutoff_date.timestamp()
|
||||||
|
|
||||||
# Collect raw posts for filtering
|
# ====================================================================
|
||||||
raw_posts = []
|
# START OF REFACTORED SECTION
|
||||||
for post_uuid, post_data in cached_posts.items():
|
# ====================================================================
|
||||||
# Apply time filter first if enabled
|
|
||||||
|
def _post_should_be_included(post_data):
|
||||||
|
"""Check if a post passes all pre-filterset criteria."""
|
||||||
|
# Apply time filter
|
||||||
if time_filter_enabled and time_cutoff:
|
if time_filter_enabled and time_cutoff:
|
||||||
post_timestamp = post_data.get('timestamp', 0)
|
if post_data.get('timestamp', 0) < time_cutoff:
|
||||||
if post_timestamp < time_cutoff:
|
return False
|
||||||
continue
|
|
||||||
# Apply community filter (before filterset)
|
# Apply community filter
|
||||||
if community and post_data.get('source', '').lower() != community.lower():
|
if community and post_data.get('source', '').lower() != community.lower():
|
||||||
continue
|
return False
|
||||||
|
|
||||||
# Apply platform filter (before filterset)
|
# Apply platform filter
|
||||||
if platform and post_data.get('platform', '').lower() != platform.lower():
|
if platform and post_data.get('platform', '').lower() != platform.lower():
|
||||||
continue
|
return False
|
||||||
|
|
||||||
# Apply user's community preferences (before filterset)
|
# Apply user's community preferences
|
||||||
if user_communities:
|
if user_communities:
|
||||||
post_source = post_data.get('source', '').lower()
|
post_source = post_data.get('source', '').lower()
|
||||||
post_platform = post_data.get('platform', '').lower()
|
post_platform = post_data.get('platform', '').lower()
|
||||||
post_id = post_data.get('id', '').lower()
|
if not any(
|
||||||
|
post_source == c or post_platform == c or c in post_source
|
||||||
|
for c in user_communities
|
||||||
|
):
|
||||||
|
# ====================================================================
|
||||||
|
# MODIFICATION: Add logging here
|
||||||
|
# ====================================================================
|
||||||
|
logger.error(
|
||||||
|
f"Post filtered out for user {current_user.id if current_user.is_authenticated else 'anonymous'}: "
|
||||||
|
f"Community mismatch. Platform='{post_platform}', Source='{post_source}', "
|
||||||
|
f"User Communities={user_communities}"
|
||||||
|
)
|
||||||
|
# ====================================================================
|
||||||
|
return False
|
||||||
|
|
||||||
# Check if this post matches any of the user's selected communities
|
# Apply search filter
|
||||||
matches_community = False
|
|
||||||
for selected_community in user_communities:
|
|
||||||
selected_community = selected_community.lower()
|
|
||||||
|
|
||||||
# Enhanced matching logic (platform-agnostic):
|
|
||||||
# 1. Exact source match (e.g., source="programming", community="programming")
|
|
||||||
# 2. Platform match (e.g., platform="hackernews", community="hackernews")
|
|
||||||
# 3. Partial match in source (e.g., source="programming", community="program")
|
|
||||||
# 4. Partial match in post ID (e.g., id="reddit_programming_123", community="programming")
|
|
||||||
if (post_source == selected_community or
|
|
||||||
post_platform == selected_community or
|
|
||||||
(selected_community in post_source) or
|
|
||||||
(selected_community in post_id)):
|
|
||||||
matches_community = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not matches_community:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Apply search filter (before filterset)
|
|
||||||
if search_query:
|
if search_query:
|
||||||
title = post_data.get('title', '').lower()
|
title = post_data.get('title', '').lower()
|
||||||
content = post_data.get('content', '').lower()
|
content = post_data.get('content', '').lower()
|
||||||
author = post_data.get('author', '').lower()
|
author = post_data.get('author', '').lower()
|
||||||
source = post_data.get('source', '').lower()
|
source = post_data.get('source', '').lower()
|
||||||
|
|
||||||
if not (search_query in title or
|
if not (search_query in title or
|
||||||
search_query in content or
|
search_query in content or
|
||||||
search_query in author or
|
search_query in author or
|
||||||
search_query in source):
|
search_query in source):
|
||||||
continue
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
raw_posts.append(post_data)
|
# Collect raw posts using a clean, declarative list comprehension
|
||||||
|
raw_posts = [
|
||||||
|
post_data for post_data in cached_posts.values()
|
||||||
|
if _post_should_be_included(post_data)
|
||||||
|
]
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# END OF REFACTORED SECTION
|
||||||
|
# ====================================================================
|
||||||
|
|
||||||
# Apply filterset using FilterEngine
|
# Apply filterset using FilterEngine
|
||||||
filtered_posts = filter_engine.apply_filterset(raw_posts, filterset_name, use_cache=True)
|
filtered_posts = filter_engine.apply_filterset(raw_posts, filterset_name, use_cache=True)
|
||||||
@@ -1405,58 +1410,30 @@ def settings_communities():
|
|||||||
|
|
||||||
# Get available communities from platform config and collection targets
|
# Get available communities from platform config and collection targets
|
||||||
available_communities = []
|
available_communities = []
|
||||||
|
|
||||||
# Load platform configuration with error handling
|
# Load platform configuration
|
||||||
try:
|
platform_config = load_platform_config()
|
||||||
platform_config = load_platform_config()
|
|
||||||
if not platform_config:
|
|
||||||
platform_config = {"platforms": {}, "collection_targets": []}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error loading platform config: {e}")
|
|
||||||
platform_config = {"platforms": {}, "collection_targets": []}
|
|
||||||
|
|
||||||
# Get enabled communities from collection_targets (what's actually being crawled)
|
# Get enabled communities from collection_targets (what's actually being crawled)
|
||||||
enabled_communities = set()
|
enabled_communities = set()
|
||||||
try:
|
for target in platform_config.get('collection_targets', []):
|
||||||
for target in platform_config.get('collection_targets', []):
|
enabled_communities.add((target['platform'], target['community']))
|
||||||
if 'platform' in target and 'community' in target:
|
|
||||||
enabled_communities.add((target['platform'], target['community']))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing collection_targets: {e}")
|
|
||||||
|
|
||||||
# Build community list from platform config for communities that are enabled
|
# Build community list from platform config for communities that are enabled
|
||||||
try:
|
for platform_name, platform_info in platform_config.get('platforms', {}).items():
|
||||||
for platform_name, platform_info in platform_config.get('platforms', {}).items():
|
for community_info in platform_info.get('communities', []):
|
||||||
if not isinstance(platform_info, dict):
|
# Only include communities that are in collection_targets
|
||||||
continue
|
if (platform_name, community_info['id']) in enabled_communities:
|
||||||
communities = platform_info.get('communities', [])
|
available_communities.append({
|
||||||
if not isinstance(communities, list):
|
'id': community_info['id'],
|
||||||
continue
|
'name': community_info['name'],
|
||||||
|
'display_name': community_info.get('display_name', community_info['name']),
|
||||||
for community_info in communities:
|
'platform': platform_name,
|
||||||
try:
|
'icon': community_info.get('icon', platform_info.get('icon', '📄')),
|
||||||
if not isinstance(community_info, dict):
|
'description': community_info.get('description', '')
|
||||||
continue
|
})
|
||||||
|
|
||||||
# Only include communities that are in collection_targets
|
return render_template('settings_communities.html',
|
||||||
if (platform_name, community_info['id']) in enabled_communities:
|
|
||||||
available_communities.append({
|
|
||||||
'id': community_info['id'],
|
|
||||||
'name': community_info['name'],
|
|
||||||
'display_name': community_info.get('display_name', community_info['name']),
|
|
||||||
'platform': platform_name,
|
|
||||||
'icon': community_info.get('icon', platform_info.get('icon', '📄')),
|
|
||||||
'description': community_info.get('description', '')
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing community {community_info}: {e}")
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error building community list: {e}")
|
|
||||||
|
|
||||||
logger.info(f"Found {len(available_communities)} available communities")
|
|
||||||
|
|
||||||
return render_template('settings_communities.html',
|
|
||||||
user=current_user,
|
user=current_user,
|
||||||
available_communities=available_communities,
|
available_communities=available_communities,
|
||||||
selected_communities=selected_communities)
|
selected_communities=selected_communities)
|
||||||
@@ -1562,101 +1539,60 @@ def settings_experience():
|
|||||||
@login_required
|
@login_required
|
||||||
def upload_avatar():
|
def upload_avatar():
|
||||||
"""Upload profile picture"""
|
"""Upload profile picture"""
|
||||||
|
if 'avatar' not in request.files:
|
||||||
|
flash('No file selected', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
file = request.files['avatar']
|
||||||
|
if file.filename == '':
|
||||||
|
flash('No file selected', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Validate file type and size
|
||||||
|
if not _is_allowed_file(file.filename):
|
||||||
|
flash('Invalid file type. Please upload PNG, JPG, or GIF', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Check file size (Flask's MAX_CONTENT_LENGTH handles this too, but double-check)
|
||||||
|
if hasattr(file, 'content_length') and file.content_length > app.config['MAX_CONTENT_LENGTH']:
|
||||||
|
flash('File too large. Maximum size is 16MB', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Validate and secure filename
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
if not filename or len(filename) > MAX_FILENAME_LENGTH:
|
||||||
|
flash('Invalid filename', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Add user ID to make filename unique and prevent conflicts
|
||||||
|
unique_filename = f"{current_user.id}_{filename}"
|
||||||
|
|
||||||
|
# Ensure upload directory exists and is secure
|
||||||
|
upload_dir = os.path.abspath(UPLOAD_FOLDER)
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
upload_path = os.path.join(upload_dir, unique_filename)
|
||||||
|
|
||||||
|
# Final security check - ensure path is within upload directory
|
||||||
|
if not os.path.abspath(upload_path).startswith(upload_dir):
|
||||||
|
logger.warning(f"Path traversal attempt in file upload: {upload_path}")
|
||||||
|
flash('Invalid file path', 'error')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Debug logging
|
|
||||||
logger.info(f"Avatar upload attempt by user {current_user.id} ({current_user.username})")
|
|
||||||
logger.debug(f"Request files: {list(request.files.keys())}")
|
|
||||||
logger.debug(f"Request form: {dict(request.form)}")
|
|
||||||
|
|
||||||
# Check if user is properly authenticated and has required attributes
|
|
||||||
if not hasattr(current_user, 'id') or not current_user.id:
|
|
||||||
logger.error("User missing ID attribute")
|
|
||||||
flash('Authentication error. Please log in again.', 'error')
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
if not hasattr(current_user, 'username') or not current_user.username:
|
|
||||||
logger.error("User missing username attribute")
|
|
||||||
flash('User profile incomplete. Please update your profile.', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
# Check for file in request
|
|
||||||
if 'avatar' not in request.files:
|
|
||||||
logger.warning("No avatar file in request")
|
|
||||||
flash('No file selected', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
file = request.files['avatar']
|
|
||||||
if file.filename == '':
|
|
||||||
logger.warning("Empty filename provided")
|
|
||||||
flash('No file selected', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
logger.info(f"Processing file: {file.filename}")
|
|
||||||
|
|
||||||
# Validate file type and size
|
|
||||||
if not _is_allowed_file(file.filename):
|
|
||||||
logger.warning(f"Invalid file type: {file.filename}")
|
|
||||||
flash('Invalid file type. Please upload PNG, JPG, or GIF', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
# Check file size (Flask's MAX_CONTENT_LENGTH handles this too, but double-check)
|
|
||||||
if hasattr(file, 'content_length') and file.content_length > app.config.get('MAX_CONTENT_LENGTH', 16*1024*1024):
|
|
||||||
logger.warning(f"File too large: {file.content_length}")
|
|
||||||
flash('File too large. Maximum size is 16MB', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
# Validate and secure filename
|
|
||||||
filename = secure_filename(file.filename)
|
|
||||||
if not filename or len(filename) > MAX_FILENAME_LENGTH:
|
|
||||||
logger.warning(f"Invalid filename after sanitization: {filename}")
|
|
||||||
flash('Invalid filename', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
# Add user ID to make filename unique and prevent conflicts
|
|
||||||
unique_filename = f"{current_user.id}_{filename}"
|
|
||||||
logger.info(f"Generated unique filename: {unique_filename}")
|
|
||||||
|
|
||||||
# Ensure upload directory exists and is secure
|
|
||||||
upload_dir = os.path.abspath(UPLOAD_FOLDER)
|
|
||||||
os.makedirs(upload_dir, exist_ok=True)
|
|
||||||
|
|
||||||
upload_path = os.path.join(upload_dir, unique_filename)
|
|
||||||
|
|
||||||
# Final security check - ensure path is within upload directory
|
|
||||||
if not os.path.abspath(upload_path).startswith(upload_dir):
|
|
||||||
logger.warning(f"Path traversal attempt in file upload: {upload_path}")
|
|
||||||
flash('Invalid file path', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
# Save the file
|
|
||||||
file.save(upload_path)
|
file.save(upload_path)
|
||||||
logger.info(f"File saved successfully: {upload_path}")
|
logger.info(f"File uploaded successfully: {unique_filename} by user {current_user.id}")
|
||||||
|
|
||||||
# Update user profile with database error handling
|
|
||||||
old_avatar_url = current_user.profile_picture_url
|
|
||||||
current_user.profile_picture_url = f"/static/avatars/{unique_filename}"
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
logger.info(f"User profile updated successfully for {current_user.username}")
|
|
||||||
|
|
||||||
# Clean up old avatar file if it exists and was uploaded by user
|
|
||||||
if old_avatar_url and old_avatar_url.startswith('/static/avatars/') and current_user.id in old_avatar_url:
|
|
||||||
try:
|
|
||||||
old_file_path = os.path.join(upload_dir, os.path.basename(old_avatar_url))
|
|
||||||
if os.path.exists(old_file_path):
|
|
||||||
os.remove(old_file_path)
|
|
||||||
logger.info(f"Cleaned up old avatar: {old_file_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not clean up old avatar: {e}")
|
|
||||||
|
|
||||||
flash('Profile picture updated successfully', 'success')
|
|
||||||
return redirect(url_for('settings_profile'))
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in avatar upload: {e}")
|
logger.error(f"Error saving uploaded file: {e}")
|
||||||
db.session.rollback()
|
flash('Error saving file', 'error')
|
||||||
flash('An unexpected error occurred. Please try again.', 'error')
|
|
||||||
return redirect(url_for('settings_profile'))
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
# Update user profile
|
||||||
|
current_user.profile_picture_url = f"/static/avatars/{unique_filename}"
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Profile picture updated successfully', 'success')
|
||||||
|
return redirect(url_for('settings_profile'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/profile')
|
@app.route('/profile')
|
||||||
|
|||||||
@@ -211,6 +211,12 @@ def collect_platform(platform: str, community: str, start_date: str, end_date: s
|
|||||||
if post_id in index:
|
if post_id in index:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# FIX: Correct the post's source field BEFORE saving
|
||||||
|
# ====================================================================
|
||||||
|
post['source'] = community if community else platform
|
||||||
|
# ====================================================================
|
||||||
|
|
||||||
# Save post
|
# Save post
|
||||||
post_uuid = save_post(post, platform, index, dirs)
|
post_uuid = save_post(post, platform, index, dirs)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
|
|||||||
@@ -292,8 +292,10 @@ class data_methods():
|
|||||||
'meta': {'is_self': post.get('is_self', False)}
|
'meta': {'is_self': post.get('is_self', False)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# In data_methods.converters.hackernews_to_schema()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hackernews_to_schema(raw):
|
def hackernews_to_schema(raw, community='front_page'): # Add community parameter
|
||||||
if not raw or raw.get('type') != 'story':
|
if not raw or raw.get('type') != 'story':
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -306,7 +308,11 @@ class data_methods():
|
|||||||
'replies': raw.get('descendants', 0),
|
'replies': raw.get('descendants', 0),
|
||||||
'url': raw.get('url', f"https://news.ycombinator.com/item?id={raw.get('id')}"),
|
'url': raw.get('url', f"https://news.ycombinator.com/item?id={raw.get('id')}"),
|
||||||
'content': raw.get('text', ''),
|
'content': raw.get('text', ''),
|
||||||
'source': 'hackernews',
|
# ====================================================================
|
||||||
|
# FIX: Use the community parameter for the source
|
||||||
|
# ====================================================================
|
||||||
|
'source': community,
|
||||||
|
# ====================================================================
|
||||||
'tags': ['hackernews'],
|
'tags': ['hackernews'],
|
||||||
'meta': {}
|
'meta': {}
|
||||||
}
|
}
|
||||||
@@ -681,7 +687,7 @@ class data_methods():
|
|||||||
stories.append(data_methods.utils.http_get_json(story_url))
|
stories.append(data_methods.utils.http_get_json(story_url))
|
||||||
|
|
||||||
# Convert and filter
|
# Convert and filter
|
||||||
posts = [data_methods.converters.hackernews_to_schema(s) for s in stories]
|
posts = [data_methods.converters.hackernews_to_schema(s, community) for s in stories]
|
||||||
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
|
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ services:
|
|||||||
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
|
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
|
||||||
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
|
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent data storage
|
# Application-managed data (using a named volume)
|
||||||
- ./data:/app/data
|
- app_data:/app/data
|
||||||
|
# User-editable content (using bind mounts)
|
||||||
- ./static:/app/static
|
- ./static:/app/static
|
||||||
- ./backups:/app/backups
|
- ./backups:/app/backups
|
||||||
- ./active_html:/app/active_html
|
- ./active_html:/app/active_html
|
||||||
@@ -71,4 +72,5 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
app_data: # <-- New named volume declared here
|
||||||
@@ -84,9 +84,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== BUTTONS ===== */
|
/* ===== BUTTONS ===== */
|
||||||
.btn,
|
.btn {
|
||||||
.btn-primary,
|
|
||||||
.btn-secondary {
|
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -96,43 +94,25 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-align: center;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
border-color: var(--primary-hover);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--surface-elevation-1);
|
background: var(--surface-elevation-1);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--divider-color);
|
border: 1px solid var(--divider-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: var(--surface-elevation-2);
|
background: var(--surface-elevation-2);
|
||||||
border-color: var(--divider-color);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled:hover {
|
|
||||||
background: var(--primary-color) !important;
|
|
||||||
border-color: var(--primary-color) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
@@ -265,22 +245,9 @@
|
|||||||
/* ===== STATS & CARDS ===== */
|
/* ===== STATS & CARDS ===== */
|
||||||
.admin-stats {
|
.admin-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
.admin-stats {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@@ -324,19 +291,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.system-info {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
.system-info {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== FORMS ===== */
|
/* ===== FORMS ===== */
|
||||||
@@ -351,152 +305,20 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control,
|
.form-control {
|
||||||
.form-input {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid var(--divider-color);
|
border: 1px solid var(--divider-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus,
|
.form-control:focus {
|
||||||
.form-input:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--divider-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background-color: var(--surface-color);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-text {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-source-form {
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
border: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-source-form h3 {
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== MODALS ===== */
|
|
||||||
.modal-overlay {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 1000;
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay.active {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid var(--divider-color);
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close:hover {
|
|
||||||
background: var(--surface-elevation-1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-alert {
|
|
||||||
background: #fff3cd;
|
|
||||||
border: 1px solid #ffeaa7;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: #856404;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== UTILITIES ===== */
|
/* ===== UTILITIES ===== */
|
||||||
.back-link {
|
.back-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -709,25 +709,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
async function loadPlatformConfig() {
|
async function loadPlatformConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/platforms');
|
const response = await fetch('/api/platforms');
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
platformConfig = data.platforms || {};
|
platformConfig = data.platforms || {};
|
||||||
communitiesData = data.communities || [];
|
communitiesData = data.communities || [];
|
||||||
|
|
||||||
console.log('Loaded communities:', communitiesData);
|
|
||||||
renderCommunities(communitiesData);
|
renderCommunities(communitiesData);
|
||||||
setupCommunityFiltering();
|
setupCommunityFiltering();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading platform configuration:', error);
|
console.error('Error loading platform configuration:', error);
|
||||||
// Show fallback communities
|
// Show fallback communities
|
||||||
const fallbackCommunities = [
|
const fallbackCommunities = [
|
||||||
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
|
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 0},
|
||||||
{platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
|
{platform: 'reddit', id: 'python', display_name: 'r/python', icon: '🐍', count: 0},
|
||||||
{platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '⚡', count: 0}
|
{platform: 'hackernews', id: 'hackernews', display_name: 'Hacker News', icon: '🧮', count: 0}
|
||||||
];
|
];
|
||||||
communitiesData = fallbackCommunities;
|
|
||||||
renderCommunities(fallbackCommunities);
|
renderCommunities(fallbackCommunities);
|
||||||
setupCommunityFiltering();
|
setupCommunityFiltering();
|
||||||
}
|
}
|
||||||
@@ -785,17 +780,10 @@ function renderFilters(filters) {
|
|||||||
function renderCommunities(communities) {
|
function renderCommunities(communities) {
|
||||||
const communityList = document.getElementById('community-list');
|
const communityList = document.getElementById('community-list');
|
||||||
if (!communityList) return;
|
if (!communityList) return;
|
||||||
|
|
||||||
console.log('Rendering communities:', communities);
|
if (communities.length === 0) {
|
||||||
|
communityList.innerHTML = '<div class="no-communities">No communities available</div>';
|
||||||
if (!communities || communities.length === 0) {
|
return;
|
||||||
// Always show fallback communities if none are loaded
|
|
||||||
const fallbackCommunities = [
|
|
||||||
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
|
|
||||||
{platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
|
|
||||||
{platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '⚡', count: 0}
|
|
||||||
];
|
|
||||||
communities = fallbackCommunities;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add "All Communities" option at the top
|
// Add "All Communities" option at the top
|
||||||
|
|||||||
@@ -3,27 +3,67 @@
|
|||||||
{% block title %}{{ post.title }} - {{ APP_NAME }}{% endblock %}
|
{% block title %}{{ post.title }} - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include '_nav.html' %}
|
<!-- Modern Top Navigation -->
|
||||||
|
<nav class="top-nav">
|
||||||
|
<div class="nav-content">
|
||||||
|
<div class="nav-left">
|
||||||
|
<div class="logo-section">
|
||||||
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }}" class="nav-logo">
|
||||||
|
<span class="brand-text">{{ APP_NAME }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-center">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" placeholder="Search content..." class="search-input">
|
||||||
|
<button class="search-btn">🔍</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-right">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="user-menu">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
|
{% if current_user.profile_picture_url %}
|
||||||
|
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
||||||
|
{% else %}
|
||||||
|
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="username">{{ current_user.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-dropdown">
|
||||||
|
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="auth-buttons">
|
||||||
|
<a href="{{ url_for('login') }}" class="auth-btn">Login</a>
|
||||||
|
<a href="{{ url_for('signup') }}" class="auth-btn primary">Sign Up</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="main-content single-post">
|
<main class="main-content single-post">
|
||||||
<!-- Back Button -->
|
<!-- Back Button -->
|
||||||
<div class="back-section">
|
<div class="back-section">
|
||||||
<a href="#" onclick="goBackToFeed(event)" class="back-btn">← Back to Feed</a>
|
<button onclick="goBackToFeed()" class="back-btn">← Back to Feed</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post Content -->
|
<!-- Post Content -->
|
||||||
<article class="post-detail">
|
<article class="post-detail">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
{% if post.url and not post.url.startswith('/') %}
|
|
||||||
<a href="{{ post.url }}" target="_blank" class="platform-badge platform-{{ post.platform }}" title="View on {{ post.platform.title() }}">
|
|
||||||
{{ post.platform.title()[:1] }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<div class="platform-badge platform-{{ post.platform }}">
|
<div class="platform-badge platform-{{ post.platform }}">
|
||||||
{{ post.platform.title()[:1] }}
|
{{ post.platform.title()[:1] }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<div class="post-meta">
|
<div class="post-meta">
|
||||||
<span class="post-author">{{ post.author }}</span>
|
<span class="post-author">{{ post.author }}</span>
|
||||||
<span class="post-separator">•</span>
|
<span class="post-separator">•</span>
|
||||||
@@ -162,14 +202,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-left .logo-section:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-logo {
|
.nav-logo {
|
||||||
@@ -327,35 +359,6 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.anonymous-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-btn, .register-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-btn {
|
|
||||||
color: #2c3e50;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-btn {
|
|
||||||
background: #4db6ac;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-btn:hover, .register-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Content */
|
/* Main Content */
|
||||||
.main-content.single-post {
|
.main-content.single-post {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
@@ -657,23 +660,13 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function goBackToFeed(event) {
|
function goBackToFeed() {
|
||||||
event.preventDefault();
|
// Try to go back to the dashboard if possible
|
||||||
|
if (document.referrer && document.referrer.includes(window.location.origin)) {
|
||||||
// Try to go back in browser history first
|
|
||||||
if (window.history.length > 1 && document.referrer && document.referrer.includes(window.location.origin)) {
|
|
||||||
window.history.back();
|
window.history.back();
|
||||||
} else {
|
} else {
|
||||||
// Fallback to dashboard - construct URL with current query parameters
|
// Fallback to dashboard
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
window.location.href = '/';
|
||||||
const baseUrl = {{ url_for('index')|tojson }};
|
|
||||||
|
|
||||||
// Add query parameters if they exist
|
|
||||||
if (urlParams.toString()) {
|
|
||||||
window.location.href = baseUrl + '?' + urlParams.toString();
|
|
||||||
} else {
|
|
||||||
window.location.href = baseUrl;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
.settings-container {
|
.settings-container {
|
||||||
max-width: 1200px;
|
max-width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
@@ -83,21 +83,8 @@
|
|||||||
|
|
||||||
.community-grid {
|
.community-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.community-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1400px) {
|
|
||||||
.community-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.community-item {
|
.community-item {
|
||||||
@@ -250,12 +237,6 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% include '_nav.html' %}
|
{% include '_nav.html' %}
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<nav style="margin-bottom: 24px;">
|
|
||||||
<a href="{{ url_for('settings') }}" style="color: var(--primary-color); text-decoration: none; font-weight: 500;">
|
|
||||||
← Back to Settings
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Community Settings</h1>
|
<h1>Community Settings</h1>
|
||||||
<p>Select which communities, subreddits, and sources to include in your feed</p>
|
<p>Select which communities, subreddits, and sources to include in your feed</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user