tuning ui and adding adaptive themeing to extension

This commit is contained in:
Bryan Van Deusen
2026-01-25 18:47:06 -05:00
parent dc6755a0c2
commit 997f31ae27
23 changed files with 978 additions and 114 deletions
+7 -2
View File
@@ -16,6 +16,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
libpq-dev \ libpq-dev \
gosu \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Python dependencies # Install Python dependencies
@@ -28,14 +29,18 @@ COPY backend/ .
# Copy built frontend from Stage 1 # Copy built frontend from Stage 1
COPY --from=frontend-build /frontend/dist /app/static COPY --from=frontend-build /frontend/dist /app/static
# Copy and set up entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Create data directories # Create data directories
RUN mkdir -p /data/downloads /data/config /data/cookies RUN mkdir -p /data/downloads /data/config /data/cookies
# Run as non-root user # Create non-root user (entrypoint will switch to this user after fixing permissions)
RUN useradd -m -u 1000 appuser && \ RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app /data chown -R appuser:appuser /app /data
USER appuser
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["hypercorn", "app.main:app", "--bind", "0.0.0.0:8080"] CMD ["hypercorn", "app.main:app", "--bind", "0.0.0.0:8080"]
+5 -1
View File
@@ -24,8 +24,12 @@ async def list_credentials():
result = await session.execute(select(Credential)) result = await session.execute(select(Credential))
credentials = result.scalars().all() credentials = result.scalars().all()
# Also return a simple map of platform -> has_credentials for UI convenience
platforms_with_creds = {c.platform: True for c in credentials}
return jsonify({ return jsonify({
"items": [c.to_dict(include_data=False) for c in credentials] "items": [c.to_dict(include_data=False) for c in credentials],
"platforms": platforms_with_creds,
}) })
+40
View File
@@ -128,6 +128,46 @@ async def retry_download(download_id: int):
}), 202 }), 202
@bp.route("/recent-activity", methods=["GET"])
async def recent_activity():
"""Get recent noteworthy downloads: failures and completions with new files.
These are downloads that warrant user attention, not routine "no new content" runs.
Results are limited to the last 7 days by default.
"""
limit = min(int(request.args.get("limit", 10)), 50)
days = int(request.args.get("days", 7))
from datetime import timedelta
cutoff = datetime.utcnow() - timedelta(days=days)
async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn)
# Get failed downloads OR completed with files (noteworthy activity)
from sqlalchemy import or_
query = select(Download).where(
and_(
Download.created_at >= cutoff,
or_(
Download.status == DownloadStatus.FAILED,
and_(
Download.status == DownloadStatus.COMPLETED,
Download.file_count > 0
)
)
)
).order_by(Download.created_at.desc()).limit(limit)
result = await session.execute(query)
downloads = result.scalars().all()
return jsonify({
"items": [d.to_dict() for d in downloads],
"count": len(downloads),
})
@bp.route("/stats", methods=["GET"]) @bp.route("/stats", methods=["GET"])
async def get_stats(): async def get_stats():
"""Get download statistics.""" """Get download statistics."""
+80 -1
View File
@@ -1,6 +1,7 @@
"""Settings API endpoints.""" """Settings API endpoints."""
import json import json
import os
from pathlib import Path from pathlib import Path
from quart import Blueprint, request, jsonify, current_app from quart import Blueprint, request, jsonify, current_app
from sqlalchemy import select from sqlalchemy import select
@@ -12,6 +13,45 @@ from app.config import get_settings
bp = Blueprint("settings", __name__) bp = Blueprint("settings", __name__)
def get_storage_stats():
"""Calculate storage statistics for the downloads directory."""
config = get_settings()
downloads_path = Path(config.downloads_path)
if not downloads_path.exists():
return {"total_size": 0, "total_size_formatted": "0 B", "file_count": 0}
total_size = 0
file_count = 0
try:
for root, dirs, files in os.walk(downloads_path):
for f in files:
fp = os.path.join(root, f)
try:
total_size += os.path.getsize(fp)
file_count += 1
except OSError:
pass
except Exception as e:
current_app.logger.error(f"Error calculating storage stats: {e}")
return {"total_size": 0, "total_size_formatted": "Error", "file_count": 0}
# Format size
def format_size(size):
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} PB"
return {
"total_size": total_size,
"total_size_formatted": format_size(total_size),
"file_count": file_count,
}
async def get_or_create_extension_api_key(session: AsyncSession) -> str: async def get_or_create_extension_api_key(session: AsyncSession) -> str:
"""Get the extension API key, creating one if it doesn't exist.""" """Get the extension API key, creating one if it doesn't exist."""
result = await session.execute( result = await session.execute(
@@ -72,7 +112,10 @@ async def get_all_settings():
api_key = await get_or_create_extension_api_key(session) api_key = await get_or_create_extension_api_key(session)
settings_dict[EXTENSION_API_KEY_SETTING] = api_key settings_dict[EXTENSION_API_KEY_SETTING] = api_key
return jsonify({"settings": settings_dict}) # Include storage statistics
storage_stats = get_storage_stats()
return jsonify({"settings": settings_dict, "storage_stats": storage_stats})
@bp.route("", methods=["PATCH"]) @bp.route("", methods=["PATCH"])
@@ -186,3 +229,39 @@ async def update_gallery_dl_config():
except Exception as e: except Exception as e:
current_app.logger.error(f"Failed to write gallery-dl config: {e}") current_app.logger.error(f"Failed to write gallery-dl config: {e}")
return jsonify({"error": f"Failed to write config: {e}"}), 500 return jsonify({"error": f"Failed to write config: {e}"}), 500
@bp.route("/logs", methods=["GET"])
async def get_logs():
"""Get recent download logs from download metadata.
Returns the stdout/stderr from recent downloads for debugging.
"""
from app.models.download import Download
from sqlalchemy import desc
limit = min(int(request.args.get("limit", 50)), 200)
async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn)
# Get recent downloads with output logs
query = select(Download).order_by(desc(Download.created_at)).limit(limit)
result = await session.execute(query)
downloads = result.scalars().all()
logs = []
for dl in downloads:
metadata = dl.metadata_ or {}
if metadata.get("stdout") or metadata.get("stderr") or dl.error_message:
logs.append({
"id": dl.id,
"url": dl.url,
"status": dl.status,
"created_at": dl.created_at.isoformat() + "Z" if dl.created_at else None,
"stdout": metadata.get("stdout", ""),
"stderr": metadata.get("stderr", ""),
"error_message": dl.error_message,
})
return jsonify({"logs": logs, "count": len(logs)})
+26 -5
View File
@@ -1,6 +1,6 @@
"""SQLAlchemy base model.""" """SQLAlchemy base model."""
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -10,8 +10,29 @@ class Base(DeclarativeBase):
pass pass
class TimestampMixin: def utcnow():
"""Mixin for created_at and updated_at timestamps.""" """Return current UTC time as timezone-aware datetime."""
return datetime.now(timezone.utc)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) def format_datetime(dt):
"""Format datetime as ISO string with UTC marker for API responses.
If the datetime is naive (no timezone), assumes UTC and appends Z.
This ensures JavaScript Date() correctly interprets the timezone.
"""
if not dt:
return None
# If naive (no timezone), assume UTC and append Z
if dt.tzinfo is None:
return dt.isoformat() + "Z"
return dt.isoformat()
class TimestampMixin:
"""Mixin for created_at and updated_at timestamps.
Uses UTC timestamps for consistency. Frontend should convert to local time.
"""
created_at: Mapped[datetime] = mapped_column(default=utcnow)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
+4 -4
View File
@@ -6,7 +6,7 @@ from sqlalchemy import String, Integer, Text, ForeignKey, BigInteger
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin, format_datetime
class DownloadStatus: class DownloadStatus:
@@ -79,10 +79,10 @@ class Download(Base, TimestampMixin):
"error_message": self.error_message, "error_message": self.error_message,
"file_count": self.file_count, "file_count": self.file_count,
"total_size": self.total_size, "total_size": self.total_size,
"started_at": self.started_at.isoformat() if self.started_at else None, "started_at": format_datetime(self.started_at),
"completed_at": self.completed_at.isoformat() if self.completed_at else None, "completed_at": format_datetime(self.completed_at),
"metadata": self.metadata_, "metadata": self.metadata_,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": format_datetime(self.created_at),
} }
+5 -5
View File
@@ -6,7 +6,7 @@ from sqlalchemy import String, Boolean, Integer, Text, ForeignKey, UniqueConstra
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin, format_datetime
class Source(Base, TimestampMixin): class Source(Base, TimestampMixin):
@@ -55,12 +55,12 @@ class Source(Base, TimestampMixin):
"url": self.url, "url": self.url,
"enabled": self.enabled, "enabled": self.enabled,
"check_interval": self.check_interval, "check_interval": self.check_interval,
"last_check": self.last_check.isoformat() if self.last_check else None, "last_check": format_datetime(self.last_check),
"last_success": self.last_success.isoformat() if self.last_success else None, "last_success": format_datetime(self.last_success),
"error_count": self.error_count, "error_count": self.error_count,
"metadata": self.metadata_, "metadata": self.metadata_,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": format_datetime(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": format_datetime(self.updated_at),
} }
if include_subscription and self.subscription: if include_subscription and self.subscription:
result["subscription_name"] = self.subscription.name result["subscription_name"] = self.subscription.name
+3 -3
View File
@@ -6,7 +6,7 @@ from sqlalchemy import String, Boolean, Integer, Text
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin, format_datetime
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.source import Source from app.models.source import Source
@@ -64,8 +64,8 @@ class Subscription(Base, TimestampMixin):
"metadata": self.metadata_, "metadata": self.metadata_,
"platform_count": self.platform_count, "platform_count": self.platform_count,
"enabled_source_count": self.enabled_source_count, "enabled_source_count": self.enabled_source_count,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": format_datetime(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": format_datetime(self.updated_at),
} }
if include_sources and self.sources: if include_sources and self.sources:
result["sources"] = [s.to_dict(include_subscription=False) for s in self.sources] result["sources"] = [s.to_dict(include_subscription=False) for s in self.sources]
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
set -e
# Fix ownership of data directories (needed for bind mounts)
# Only fix top-level dirs to avoid slow recursive chown on large downloads
echo "Fixing data directory permissions..."
chown appuser:appuser /data /data/downloads /data/config /data/cookies 2>/dev/null || true
# Wait for database to be ready
echo "Waiting for database..."
until gosu appuser python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import get_settings
async def check():
settings = get_settings()
engine = create_async_engine(settings.async_database_url)
async with engine.connect() as conn:
await conn.execute(__import__('sqlalchemy').text('SELECT 1'))
await engine.dispose()
asyncio.run(check())
" 2>/dev/null; do
echo "Database not ready, waiting..."
sleep 2
done
echo "Database is ready!"
# Only run migrations from the app container (not celery worker)
# This prevents race conditions when multiple containers start simultaneously
if [[ "$1" == "hypercorn" ]]; then
echo "Running database migrations..."
gosu appuser alembic upgrade head
echo "Migrations complete!"
else
echo "Skipping migrations (will be run by app container)"
fi
# Start the application as non-root user
exec gosu appuser "$@"
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#1976D2" d="M20,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6A2,2 0 0,0 20,4M4,18V6H20V18H4M6,10H8V8H6V10M6,14H8V12H6V14M10,10H18V8H10V10M10,14H18V12H10V14Z"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+3 -10
View File
@@ -19,12 +19,7 @@
"browser_action": { "browser_action": {
"default_popup": "popup/popup.html", "default_popup": "popup/popup.html",
"default_icon": { "default_icon": "icons/icon.svg",
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"default_title": "GallerySubscriber" "default_title": "GallerySubscriber"
}, },
@@ -44,9 +39,7 @@
}, },
"icons": { "icons": {
"16": "icons/icon-16.png", "48": "icons/icon.svg",
"32": "icons/icon-32.png", "96": "icons/icon.svg"
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
} }
} }
+73 -11
View File
@@ -4,8 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>GallerySubscriber Settings</title> <title>GallerySubscriber Settings</title>
<style> <style>
/* Light theme (default) */
:root { :root {
--primary-color: #1976d2; --primary-color: #1976d2;
--primary-hover: #1565c0;
--success-color: #4caf50; --success-color: #4caf50;
--error-color: #f44336; --error-color: #f44336;
--bg-color: #f5f5f5; --bg-color: #f5f5f5;
@@ -13,6 +15,64 @@
--text-color: #212121; --text-color: #212121;
--text-secondary: #757575; --text-secondary: #757575;
--border-color: #e0e0e0; --border-color: #e0e0e0;
--input-bg: #ffffff;
--btn-secondary-bg: #f5f5f5;
--btn-secondary-hover: #eeeeee;
--instructions-bg: #e3f2fd;
--instructions-title: #1565c0;
--code-bg: rgba(0,0,0,0.08);
--result-success-bg: #e8f5e9;
--result-success-text: #2e7d32;
--result-error-bg: #ffebee;
--result-error-text: #c62828;
}
/* Dark theme */
[data-theme="dark"] {
--primary-color: #64b5f6;
--primary-hover: #42a5f5;
--success-color: #81c784;
--error-color: #e57373;
--bg-color: #121212;
--card-bg: #1e1e1e;
--text-color: #e0e0e0;
--text-secondary: #9e9e9e;
--border-color: #424242;
--input-bg: #2d2d2d;
--btn-secondary-bg: #2d2d2d;
--btn-secondary-hover: #3d3d3d;
--instructions-bg: #1a2a3a;
--instructions-title: #64b5f6;
--code-bg: rgba(255,255,255,0.1);
--result-success-bg: #1b3d1b;
--result-success-text: #81c784;
--result-error-bg: #3d1b1b;
--result-error-text: #e57373;
}
/* Auto-detect system theme preference */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary-color: #64b5f6;
--primary-hover: #42a5f5;
--success-color: #81c784;
--error-color: #e57373;
--bg-color: #121212;
--card-bg: #1e1e1e;
--text-color: #e0e0e0;
--text-secondary: #9e9e9e;
--border-color: #424242;
--input-bg: #2d2d2d;
--btn-secondary-bg: #2d2d2d;
--btn-secondary-hover: #3d3d3d;
--instructions-bg: #1a2a3a;
--instructions-title: #64b5f6;
--code-bg: rgba(255,255,255,0.1);
--result-success-bg: #1b3d1b;
--result-success-text: #81c784;
--result-error-bg: #3d1b1b;
--result-error-text: #e57373;
}
} }
* { * {
@@ -80,6 +140,8 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
background: var(--input-bg);
color: var(--text-color);
transition: border-color 0.15s; transition: border-color 0.15s;
} }
@@ -122,17 +184,17 @@
} }
.btn-primary:hover { .btn-primary:hover {
background: #1565c0; background: var(--primary-hover);
} }
.btn-secondary { .btn-secondary {
background: #f5f5f5; background: var(--btn-secondary-bg);
color: var(--text-color); color: var(--text-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #eeeeee; background: var(--btn-secondary-hover);
} }
.btn:disabled { .btn:disabled {
@@ -159,13 +221,13 @@
} }
.result.success { .result.success {
background: #e8f5e9; background: var(--result-success-bg);
color: #2e7d32; color: var(--result-success-text);
} }
.result.error { .result.error {
background: #ffebee; background: var(--result-error-bg);
color: #c62828; color: var(--result-error-text);
} }
.connection-test { .connection-test {
@@ -179,7 +241,7 @@
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: #9e9e9e; background: var(--text-secondary);
} }
.status-dot.connected { .status-dot.connected {
@@ -191,7 +253,7 @@
} }
.instructions { .instructions {
background: #e3f2fd; background: var(--instructions-bg);
border-radius: 6px; border-radius: 6px;
padding: 16px; padding: 16px;
margin-bottom: 16px; margin-bottom: 16px;
@@ -201,7 +263,7 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
color: #1565c0; color: var(--instructions-title);
} }
.instructions ol { .instructions ol {
@@ -215,7 +277,7 @@
} }
.instructions code { .instructions code {
background: rgba(0,0,0,0.08); background: var(--code-bg);
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
font-family: monospace; font-family: monospace;
+81 -18
View File
@@ -1,12 +1,75 @@
/* Light theme (default) */
:root { :root {
--primary-color: #1976d2; --primary-color: #1976d2;
--primary-hover: #1565c0;
--success-color: #4caf50; --success-color: #4caf50;
--warning-color: #ff9800; --warning-color: #ff9800;
--error-color: #f44336; --error-color: #f44336;
--bg-color: #ffffff; --bg-color: #ffffff;
--bg-secondary: #f5f5f5;
--bg-hover: #f5f9ff;
--text-color: #212121; --text-color: #212121;
--text-secondary: #757575; --text-secondary: #757575;
--border-color: #e0e0e0; --border-color: #e0e0e0;
--alert-warning-bg: #fff8e1;
--alert-warning-border: #ffcc02;
--alert-warning-text: #795548;
--status-success-bg: #e8f5e9;
--status-success-text: #2e7d32;
--status-success-border: #a5d6a7;
--status-error-bg: #ffebee;
--status-error-text: #c62828;
--status-error-border: #ef9a9a;
}
/* Dark theme */
[data-theme="dark"] {
--primary-color: #64b5f6;
--primary-hover: #42a5f5;
--success-color: #81c784;
--warning-color: #ffb74d;
--error-color: #e57373;
--bg-color: #1e1e1e;
--bg-secondary: #2d2d2d;
--bg-hover: #2a3a4a;
--text-color: #e0e0e0;
--text-secondary: #9e9e9e;
--border-color: #424242;
--alert-warning-bg: #3d3224;
--alert-warning-border: #8b6914;
--alert-warning-text: #ffcc02;
--status-success-bg: #1b3d1b;
--status-success-text: #81c784;
--status-success-border: #2e7d32;
--status-error-bg: #3d1b1b;
--status-error-text: #e57373;
--status-error-border: #c62828;
}
/* Auto-detect system theme preference */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary-color: #64b5f6;
--primary-hover: #42a5f5;
--success-color: #81c784;
--warning-color: #ffb74d;
--error-color: #e57373;
--bg-color: #1e1e1e;
--bg-secondary: #2d2d2d;
--bg-hover: #2a3a4a;
--text-color: #e0e0e0;
--text-secondary: #9e9e9e;
--border-color: #424242;
--alert-warning-bg: #3d3224;
--alert-warning-border: #8b6914;
--alert-warning-text: #ffcc02;
--status-success-bg: #1b3d1b;
--status-success-text: #81c784;
--status-success-border: #2e7d32;
--status-error-bg: #3d1b1b;
--status-error-text: #e57373;
--status-error-border: #c62828;
}
} }
* { * {
@@ -91,7 +154,7 @@ body {
.platform-card:hover:not(.disabled) { .platform-card:hover:not(.disabled) {
border-color: var(--primary-color); border-color: var(--primary-color);
background: #f5f9ff; background: var(--bg-hover);
} }
.platform-card.disabled { .platform-card.disabled {
@@ -176,17 +239,17 @@ body {
} }
.btn-primary:hover { .btn-primary:hover {
background: #1565c0; background: var(--primary-hover);
} }
.btn-secondary { .btn-secondary {
background: #f5f5f5; background: var(--bg-secondary);
color: var(--text-color); color: var(--text-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #eeeeee; background: var(--border-color);
} }
.btn:disabled { .btn:disabled {
@@ -205,7 +268,7 @@ body {
} }
.btn-icon:hover { .btn-icon:hover {
background: #f5f5f5; background: var(--bg-secondary);
color: var(--text-color); color: var(--text-color);
} }
@@ -234,9 +297,9 @@ body {
} }
.alert-warning { .alert-warning {
background: #fff8e1; background: var(--alert-warning-bg);
border: 1px solid #ffcc02; border: 1px solid var(--alert-warning-border);
color: #795548; color: var(--alert-warning-text);
} }
.status-message { .status-message {
@@ -247,21 +310,21 @@ body {
} }
.status-message.success { .status-message.success {
background: #e8f5e9; background: var(--status-success-bg);
color: #2e7d32; color: var(--status-success-text);
border: 1px solid #a5d6a7; border: 1px solid var(--status-success-border);
} }
.status-message.error { .status-message.error {
background: #ffebee; background: var(--status-error-bg);
color: #c62828; color: var(--status-error-text);
border: 1px solid #ef9a9a; border: 1px solid var(--status-error-border);
} }
.status-message.warning { .status-message.warning {
background: #fff8e1; background: var(--alert-warning-bg);
color: #795548; color: var(--alert-warning-text);
border: 1px solid #ffcc02; border: 1px solid var(--alert-warning-border);
} }
.footer { .footer {
@@ -274,7 +337,7 @@ body {
.version { .version {
font-size: 11px; font-size: 11px;
color: #9e9e9e; color: var(--text-secondary);
} }
.hidden { .hidden {
+1 -1
View File
@@ -7,7 +7,7 @@
<body> <body>
<div class="popup-container"> <div class="popup-container">
<header class="header"> <header class="header">
<img src="../icons/icon-32.png" alt="GallerySubscriber" class="logo"> <img src="../icons/icon.svg" alt="GallerySubscriber" class="logo">
<h1>GallerySubscriber</h1> <h1>GallerySubscriber</h1>
<span id="connection-status" class="status-indicator" title="Disconnected"></span> <span id="connection-status" class="status-indicator" title="Disconnected"></span>
</header> </header>
+10 -2
View File
@@ -58,9 +58,14 @@ const route = useRoute()
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore()
const wsStore = useWebSocketStore() const wsStore = useWebSocketStore()
// Initialize WebSocket listeners // Initialize WebSocket listeners and restore theme
onMounted(() => { onMounted(() => {
wsStore.init() wsStore.init()
// Restore theme preference from localStorage
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
theme.global.name.value = savedTheme
}
}) })
const drawer = ref(true) const drawer = ref(true)
@@ -70,6 +75,7 @@ const navItems = [
{ title: 'Subscriptions', icon: 'mdi-account-multiple', path: '/subscriptions' }, { title: 'Subscriptions', icon: 'mdi-account-multiple', path: '/subscriptions' },
{ title: 'Downloads', icon: 'mdi-download', path: '/downloads' }, { title: 'Downloads', icon: 'mdi-download', path: '/downloads' },
{ title: 'Credentials', icon: 'mdi-key', path: '/credentials' }, { title: 'Credentials', icon: 'mdi-key', path: '/credentials' },
{ title: 'Logs', icon: 'mdi-text-box-outline', path: '/logs' },
{ title: 'Settings', icon: 'mdi-cog', path: '/settings' }, { title: 'Settings', icon: 'mdi-cog', path: '/settings' },
] ]
@@ -81,7 +87,9 @@ const currentPageTitle = computed(() => {
const isDark = computed(() => theme.global.current.value.dark) const isDark = computed(() => theme.global.current.value.dark)
const toggleTheme = () => { const toggleTheme = () => {
theme.global.name.value = isDark.value ? 'light' : 'dark' const newTheme = isDark.value ? 'light' : 'dark'
theme.global.name.value = newTheme
localStorage.setItem('theme', newTheme)
} }
const notification = computed(() => notificationStore) const notification = computed(() => notificationStore)
+5
View File
@@ -31,6 +31,11 @@ const routes = [
name: 'Settings', name: 'Settings',
component: () => import('../views/Settings.vue'), component: () => import('../views/Settings.vue'),
}, },
{
path: '/logs',
name: 'Logs',
component: () => import('../views/Logs.vue'),
},
] ]
const router = createRouter({ const router = createRouter({
+2
View File
@@ -36,6 +36,7 @@ export const downloadsApi = {
get: (id) => api.get(`/downloads/${id}`), get: (id) => api.get(`/downloads/${id}`),
retry: (id) => api.post(`/downloads/${id}/retry`), retry: (id) => api.post(`/downloads/${id}/retry`),
stats: (params = {}) => api.get('/downloads/stats', { params }), stats: (params = {}) => api.get('/downloads/stats', { params }),
recentActivity: (params = {}) => api.get('/downloads/recent-activity', { params }),
} }
// Credentials API // Credentials API
@@ -54,6 +55,7 @@ export const settingsApi = {
updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }), updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }),
getApiKey: () => api.get('/settings/api-key'), getApiKey: () => api.get('/settings/api-key'),
regenerateApiKey: () => api.post('/settings/api-key/regenerate'), regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
getLogs: (params = {}) => api.get('/settings/logs', { params }),
} }
// Platforms API // Platforms API
+33
View File
@@ -0,0 +1,33 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { credentialsApi } from '../services/api'
export const useCredentialsStore = defineStore('credentials', () => {
const credentials = ref([])
const platforms = ref({})
const loading = ref(false)
async function fetchCredentials() {
loading.value = true
try {
const response = await credentialsApi.list()
credentials.value = response.data.items
platforms.value = response.data.platforms || {}
return response.data
} finally {
loading.value = false
}
}
function hasPlatformCredentials(platform) {
return !!platforms.value[platform]
}
return {
credentials,
platforms,
loading,
fetchCredentials,
hasPlatformCredentials,
}
})
+11 -2
View File
@@ -4,6 +4,7 @@ import { downloadsApi } from '../services/api'
export const useDownloadsStore = defineStore('downloads', () => { export const useDownloadsStore = defineStore('downloads', () => {
const downloads = ref([]) const downloads = ref([])
const recentActivity = ref([])
const stats = ref(null) const stats = ref(null)
const loading = ref(false) const loading = ref(false)
const total = ref(0) const total = ref(0)
@@ -13,9 +14,9 @@ export const useDownloadsStore = defineStore('downloads', () => {
async function fetchDownloads(params = {}) { async function fetchDownloads(params = {}) {
loading.value = true loading.value = true
try { try {
// Use provided per_page or default to 1000 for client-side pagination
const response = await downloadsApi.list({ const response = await downloadsApi.list({
page: currentPage.value, per_page: params.per_page || 1000,
per_page: perPage.value,
...params, ...params,
}) })
downloads.value = response.data.items downloads.value = response.data.items
@@ -26,6 +27,12 @@ export const useDownloadsStore = defineStore('downloads', () => {
} }
} }
async function fetchRecentActivity(params = {}) {
const response = await downloadsApi.recentActivity(params)
recentActivity.value = response.data.items
return response.data
}
async function fetchStats(params = {}) { async function fetchStats(params = {}) {
const response = await downloadsApi.stats(params) const response = await downloadsApi.stats(params)
stats.value = response.data stats.value = response.data
@@ -38,12 +45,14 @@ export const useDownloadsStore = defineStore('downloads', () => {
return { return {
downloads, downloads,
recentActivity,
stats, stats,
loading, loading,
total, total,
currentPage, currentPage,
perPage, perPage,
fetchDownloads, fetchDownloads,
fetchRecentActivity,
fetchStats, fetchStats,
retryDownload, retryDownload,
} }
+2 -2
View File
@@ -15,9 +15,9 @@ export const useSubscriptionsStore = defineStore('subscriptions', () => {
async function fetchSubscriptions(params = {}) { async function fetchSubscriptions(params = {}) {
loading.value = true loading.value = true
try { try {
// Fetch all items for client-side pagination in v-data-table
const response = await subscriptionsApi.list({ const response = await subscriptionsApi.list({
page: currentPage.value, per_page: 1000,
per_page: perPage.value,
...params, ...params,
}) })
subscriptions.value = response.data.items subscriptions.value = response.data.items
+209 -46
View File
@@ -59,45 +59,101 @@
</v-col> </v-col>
</v-row> </v-row>
<!-- Quick Actions -->
<v-row class="mt-4"> <v-row class="mt-4">
<!-- Downloads by Platform --> <v-col cols="12">
<v-col cols="12" md="6">
<v-card> <v-card>
<v-card-title>Downloads by Platform</v-card-title> <v-card-text class="d-flex align-center ga-4 flex-wrap">
<v-btn
color="primary"
prepend-icon="mdi-refresh"
:loading="checkingAll"
@click="checkAllSources"
>
Check All Sources
</v-btn>
<v-btn
color="warning"
prepend-icon="mdi-replay"
:loading="retryingAll"
:disabled="!failedCount"
@click="retryAllFailed"
>
Retry All Failed ({{ failedCount }})
</v-btn>
<v-spacer />
<div class="text-body-2 text-grey">
<v-icon size="small" class="mr-1">mdi-harddisk</v-icon>
Storage: {{ storageStats?.total_size_formatted || 'Calculating...' }}
<span v-if="storageStats?.file_count" class="ml-2">
({{ storageStats.file_count.toLocaleString() }} files)
</span>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4">
<!-- Running Downloads (auto-refresh) -->
<v-col cols="12" md="4">
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" :class="{ 'mdi-spin': runningDownloads.length }">
{{ runningDownloads.length ? 'mdi-loading' : 'mdi-download' }}
</v-icon>
Running
<v-chip v-if="runningDownloads.length" size="small" color="info" class="ml-2">
{{ runningDownloads.length }}
</v-chip>
<v-spacer />
<v-btn
v-if="runningDownloads.length"
icon
size="x-small"
variant="text"
@click="refreshRunning"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
</v-card-title>
<v-card-text> <v-card-text>
<v-list v-if="stats?.by_platform"> <v-list v-if="runningDownloads.length" density="compact">
<v-list-item <v-list-item v-for="dl in runningDownloads" :key="dl.id">
v-for="(count, platform) in stats.by_platform" <v-list-item-title class="text-body-2">
:key="platform" {{ truncate(dl.url, 30) }}
>
<template v-slot:prepend>
<v-icon :color="getPlatformColor(platform)">
{{ getPlatformIcon(platform) }}
</v-icon>
</template>
<v-list-item-title class="text-capitalize">
{{ platform }}
</v-list-item-title> </v-list-item-title>
<template v-slot:append> <v-list-item-subtitle>
<v-chip size="small">{{ count }}</v-chip> <v-progress-linear
</template> indeterminate
color="info"
height="4"
class="mt-1"
/>
</v-list-item-subtitle>
</v-list-item> </v-list-item>
</v-list> </v-list>
<div v-else class="text-center text-grey py-4"> <div v-else class="text-center text-grey py-4">
No download data yet <v-icon size="32" class="mb-2">mdi-check-circle-outline</v-icon>
<div>No active downloads</div>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
<!-- Recent Activity --> <!-- Recent Activity -->
<v-col cols="12" md="6"> <v-col cols="12" md="8">
<v-card> <v-card>
<v-card-title>Recent Downloads</v-card-title> <v-card-title>
Recent Activity
<v-chip v-if="recentActivity.length" size="small" class="ml-2" variant="tonal">
Last 7 days
</v-chip>
</v-card-title>
<v-card-text> <v-card-text>
<v-list v-if="recentDownloads.length"> <v-list v-if="recentActivity.length">
<v-list-item <v-list-item
v-for="download in recentDownloads" v-for="download in recentActivity"
:key="download.id" :key="download.id"
> >
<template v-slot:prepend> <template v-slot:prepend>
@@ -109,7 +165,10 @@
{{ truncate(download.url, 40) }} {{ truncate(download.url, 40) }}
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ formatDate(download.created_at) }} {{ formatRelativeTime(download.created_at) }}
<span v-if="download.file_count > 0" class="text-success ml-2">
{{ download.file_count }} new file{{ download.file_count !== 1 ? 's' : '' }}
</span>
</v-list-item-subtitle> </v-list-item-subtitle>
<template v-slot:append> <template v-slot:append>
<v-chip <v-chip
@@ -117,13 +176,13 @@
size="small" size="small"
variant="tonal" variant="tonal"
> >
{{ download.status }} {{ download.status === 'completed' ? 'new content' : download.status }}
</v-chip> </v-chip>
</template> </template>
</v-list-item> </v-list-item>
</v-list> </v-list>
<div v-else class="text-center text-grey py-4"> <div v-else class="text-center text-grey py-4">
No recent downloads No recent activity (failures or new downloads)
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
@@ -196,47 +255,135 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useSourcesStore } from '../stores/sources' import { useSourcesStore } from '../stores/sources'
import { useDownloadsStore } from '../stores/downloads' import { useDownloadsStore } from '../stores/downloads'
import { useNotificationStore } from '../stores/notifications' import { useNotificationStore } from '../stores/notifications'
import { settingsApi } from '../services/api'
const sourcesStore = useSourcesStore() const sourcesStore = useSourcesStore()
const downloadsStore = useDownloadsStore() const downloadsStore = useDownloadsStore()
const notifications = useNotificationStore() const notifications = useNotificationStore()
// State
const checkingAll = ref(false)
const retryingAll = ref(false)
const storageStats = ref(null)
const runningDownloads = ref([])
let refreshInterval = null
// Computed
const stats = computed(() => downloadsStore.stats) const stats = computed(() => downloadsStore.stats)
const recentDownloads = computed(() => downloadsStore.downloads.slice(0, 5)) const recentActivity = computed(() => downloadsStore.recentActivity)
const sourcesWithErrors = computed(() => const sourcesWithErrors = computed(() =>
sourcesStore.sources.filter(s => s.error_count > 0).slice(0, 5) sourcesStore.sources.filter(s => s.error_count > 0).slice(0, 5)
) )
const failedCount = computed(() => stats.value?.failed || 0)
onMounted(async () => { onMounted(async () => {
await loadDashboardData()
// Start auto-refresh for running downloads
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
async function loadDashboardData() {
await Promise.all([ await Promise.all([
sourcesStore.fetchSources(), sourcesStore.fetchSources(),
downloadsStore.fetchStats(), downloadsStore.fetchStats(),
downloadsStore.fetchDownloads({ per_page: 5 }), downloadsStore.fetchRecentActivity({ limit: 10 }),
fetchRunningDownloads(),
fetchStorageStats(),
]) ])
})
function getPlatformIcon(platform) {
const icons = {
patreon: 'mdi-patreon',
subscribestar: 'mdi-star',
hentaifoundry: 'mdi-palette',
discord: 'mdi-discord',
}
return icons[platform] || 'mdi-web'
} }
function getPlatformColor(platform) { async function fetchRunningDownloads() {
const colors = { try {
patreon: '#FF424D', const response = await downloadsStore.fetchDownloads({ status: 'running', per_page: 20 })
subscribestar: '#FFD700', runningDownloads.value = response.items.filter(d => d.status === 'running')
hentaifoundry: '#9C27B0', } catch (e) {
discord: '#5865F2', console.error('Failed to fetch running downloads:', e)
}
}
async function fetchStorageStats() {
try {
const response = await settingsApi.get()
storageStats.value = response.data.storage_stats
} catch (e) {
console.error('Failed to fetch storage stats:', e)
}
}
function startAutoRefresh() {
// Refresh running downloads every 5 seconds when there are active downloads
refreshInterval = setInterval(async () => {
if (runningDownloads.value.length > 0 || stats.value?.pending > 0) {
await fetchRunningDownloads()
await downloadsStore.fetchStats()
await downloadsStore.fetchRecentActivity({ limit: 10 })
}
}, 5000)
}
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
}
async function refreshRunning() {
await fetchRunningDownloads()
}
async function checkAllSources() {
checkingAll.value = true
try {
const enabledSources = sourcesStore.sources.filter(s => s.enabled)
let queued = 0
for (const source of enabledSources) {
try {
await sourcesStore.triggerCheck(source.id)
queued++
} catch (e) {
console.error(`Failed to queue ${source.id}:`, e)
}
}
notifications.success(`Queued checks for ${queued} sources`)
await fetchRunningDownloads()
} catch (error) {
notifications.error(`Failed to check sources: ${error.message}`)
} finally {
checkingAll.value = false
}
}
async function retryAllFailed() {
retryingAll.value = true
try {
const response = await downloadsStore.fetchDownloads({ status: 'failed', per_page: 100 })
const failed = response.items
let retried = 0
for (const dl of failed) {
try {
await downloadsStore.retryDownload(dl.id)
retried++
} catch (e) {
console.error(`Failed to retry ${dl.id}:`, e)
}
}
notifications.success(`Queued retry for ${retried} downloads`)
await downloadsStore.fetchStats()
await fetchRunningDownloads()
} catch (error) {
notifications.error(`Failed to retry downloads: ${error.message}`)
} finally {
retryingAll.value = false
} }
return colors[platform] || 'grey'
} }
function getStatusIcon(status) { function getStatusIcon(status) {
@@ -266,6 +413,22 @@ function formatDate(dateStr) {
return new Date(dateStr).toLocaleString() return new Date(dateStr).toLocaleString()
} }
function formatRelativeTime(dateStr) {
if (!dateStr) return 'Unknown'
const date = new Date(dateStr)
const now = new Date()
const diffMs = now - date
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins} min${diffMins !== 1 ? 's' : ''} ago`
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`
return date.toLocaleDateString()
}
function truncate(str, length) { function truncate(str, length) {
if (!str) return '' if (!str) return ''
if (str.length <= length) return str if (str.length <= length) return str
+149
View File
@@ -0,0 +1,149 @@
<template>
<div>
<v-row class="mb-4">
<v-col>
<v-btn
color="primary"
prepend-icon="mdi-refresh"
@click="loadLogs"
:loading="loading"
>
Refresh
</v-btn>
</v-col>
<v-col cols="auto">
<v-select
v-model="filterStatus"
:items="statusOptions"
label="Status"
density="compact"
variant="outlined"
clearable
style="min-width: 140px"
/>
</v-col>
</v-row>
<v-card>
<v-card-title>Recent Download Logs</v-card-title>
<v-card-text>
<div v-if="loading" class="text-center py-8">
<v-progress-circular indeterminate />
</div>
<v-expansion-panels v-else-if="filteredLogs.length">
<v-expansion-panel
v-for="log in filteredLogs"
:key="log.id"
>
<v-expansion-panel-title>
<div class="d-flex align-center ga-2" style="width: 100%">
<v-chip
:color="getStatusColor(log.status)"
size="small"
variant="tonal"
>
{{ log.status }}
</v-chip>
<span class="text-truncate" style="max-width: 400px">
{{ log.url }}
</span>
<v-spacer />
<span class="text-caption text-grey">
{{ formatDate(log.created_at) }}
</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div v-if="log.error_message" class="mb-4">
<div class="text-subtitle-2 text-error">Error</div>
<v-code class="d-block pa-2 bg-error-lighten-5">{{ log.error_message }}</v-code>
</div>
<div v-if="log.stdout" class="mb-4">
<div class="text-subtitle-2">Output</div>
<pre class="log-output bg-grey-lighten-4 pa-2 rounded">{{ log.stdout }}</pre>
</div>
<div v-if="log.stderr">
<div class="text-subtitle-2 text-warning">Errors/Warnings</div>
<pre class="log-output bg-warning-lighten-5 pa-2 rounded">{{ log.stderr }}</pre>
</div>
<div v-if="!log.stdout && !log.stderr && !log.error_message" class="text-grey">
No log output available
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<div v-else class="text-center text-grey py-8">
<v-icon size="48" class="mb-2">mdi-text-box-outline</v-icon>
<div>No logs available</div>
</div>
</v-card-text>
</v-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { settingsApi } from '../services/api'
const loading = ref(false)
const logs = ref([])
const filterStatus = ref(null)
const statusOptions = [
{ title: 'Completed', value: 'completed' },
{ title: 'Failed', value: 'failed' },
{ title: 'Running', value: 'running' },
]
const filteredLogs = computed(() => {
if (!filterStatus.value) return logs.value
return logs.value.filter(l => l.status === filterStatus.value)
})
onMounted(() => {
loadLogs()
})
async function loadLogs() {
loading.value = true
try {
const response = await settingsApi.getLogs({ limit: 100 })
logs.value = response.data.logs
} catch (error) {
console.error('Failed to load logs:', error)
} finally {
loading.value = false
}
}
function getStatusColor(status) {
const colors = {
completed: 'success',
failed: 'error',
running: 'info',
pending: 'warning',
}
return colors[status] || 'grey'
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.log-output {
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow: auto;
}
</style>
+185 -1
View File
@@ -5,6 +5,29 @@
<v-btn color="primary" @click="showAddDialog = true" prepend-icon="mdi-plus"> <v-btn color="primary" @click="showAddDialog = true" prepend-icon="mdi-plus">
Add Subscription Add Subscription
</v-btn> </v-btn>
<v-btn
class="ml-2"
variant="outlined"
prepend-icon="mdi-download"
@click="exportSubscriptions"
>
Export
</v-btn>
<v-btn
class="ml-2"
variant="outlined"
prepend-icon="mdi-upload"
@click="triggerImport"
>
Import
</v-btn>
<input
ref="importInput"
type="file"
accept=".json"
style="display: none"
@change="handleImport"
/>
</v-col> </v-col>
<v-col cols="auto"> <v-col cols="auto">
<v-text-field <v-text-field
@@ -30,14 +53,32 @@
</v-col> </v-col>
</v-row> </v-row>
<!-- Bulk Actions Bar -->
<v-row v-if="selectedIds.length > 0" class="mb-2">
<v-col>
<v-card color="primary" variant="tonal" class="pa-2">
<div class="d-flex align-center ga-2">
<span>{{ selectedIds.length }} selected</span>
<v-btn size="small" variant="text" @click="bulkEnable(true)">Enable All</v-btn>
<v-btn size="small" variant="text" @click="bulkEnable(false)">Disable All</v-btn>
<v-btn size="small" variant="text" color="error" @click="bulkDelete">Delete All</v-btn>
<v-spacer />
<v-btn size="small" variant="text" @click="selectedIds = []">Clear Selection</v-btn>
</div>
</v-card>
</v-col>
</v-row>
<v-card> <v-card>
<v-data-table <v-data-table
v-model="selectedIds"
:headers="headers" :headers="headers"
:items="subscriptionsStore.subscriptions" :items="subscriptionsStore.subscriptions"
:loading="subscriptionsStore.loading" :loading="subscriptionsStore.loading"
:items-per-page="20" :items-per-page="20"
v-model:expanded="expandedIds" v-model:expanded="expandedIds"
item-value="id" item-value="id"
show-select
class="elevation-1" class="elevation-1"
> >
<template v-slot:item.name="{ item }"> <template v-slot:item.name="{ item }">
@@ -135,6 +176,12 @@
<v-icon start size="small">{{ getPlatformIcon(source.platform) }}</v-icon> <v-icon start size="small">{{ getPlatformIcon(source.platform) }}</v-icon>
{{ source.platform }} {{ source.platform }}
</v-chip> </v-chip>
<v-tooltip v-if="!credentialsStore.hasPlatformCredentials(source.platform)" location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" color="warning" size="small" class="ml-1">mdi-alert</v-icon>
</template>
No credentials for {{ source.platform }}. Add them in Settings.
</v-tooltip>
</td> </td>
<td class="text-truncate" style="max-width: 300px"> <td class="text-truncate" style="max-width: 300px">
<a :href="source.url" target="_blank" @click.stop>{{ source.url }}</a> <a :href="source.url" target="_blank" @click.stop>{{ source.url }}</a>
@@ -148,7 +195,12 @@
@update:model-value="toggleSourceEnabled(source)" @update:model-value="toggleSourceEnabled(source)"
/> />
</td> </td>
<td>{{ formatDate(source.last_check) }}</td> <td>
<div>{{ formatDate(source.last_check) }}</div>
<div v-if="source.enabled && source.last_check" class="text-caption text-grey">
Next: {{ formatNextCheck(source) }}
</div>
</td>
<td> <td>
<v-chip v-if="source.error_count > 0" color="error" size="x-small"> <v-chip v-if="source.error_count > 0" color="error" size="x-small">
{{ source.error_count }} {{ source.error_count }}
@@ -273,15 +325,19 @@
<script setup> <script setup>
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
import { useSubscriptionsStore } from '../stores/subscriptions' import { useSubscriptionsStore } from '../stores/subscriptions'
import { useCredentialsStore } from '../stores/credentials'
import { useNotificationStore } from '../stores/notifications' import { useNotificationStore } from '../stores/notifications'
const subscriptionsStore = useSubscriptionsStore() const subscriptionsStore = useSubscriptionsStore()
const credentialsStore = useCredentialsStore()
const notifications = useNotificationStore() const notifications = useNotificationStore()
const searchQuery = ref('') const searchQuery = ref('')
const filterEnabled = ref(null) const filterEnabled = ref(null)
const expandedIds = ref([]) const expandedIds = ref([])
const checkingIds = ref([]) const checkingIds = ref([])
const selectedIds = ref([])
const importInput = ref(null)
// Subscription dialog // Subscription dialog
const showAddDialog = ref(false) const showAddDialog = ref(false)
@@ -330,6 +386,7 @@ const headers = [
onMounted(() => { onMounted(() => {
loadSubscriptions() loadSubscriptions()
credentialsStore.fetchCredentials()
}) })
watch([searchQuery, filterEnabled], () => { watch([searchQuery, filterEnabled], () => {
@@ -377,6 +434,23 @@ function formatDate(dateStr) {
return new Date(dateStr).toLocaleString() return new Date(dateStr).toLocaleString()
} }
function formatNextCheck(source) {
if (!source.last_check || !source.check_interval) return 'Unknown'
const lastCheck = new Date(source.last_check)
const nextCheck = new Date(lastCheck.getTime() + source.check_interval * 1000)
const now = new Date()
if (nextCheck <= now) return 'Due now'
const diffMs = nextCheck - now
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
if (diffMins < 60) return `in ${diffMins}m`
if (diffHours < 24) return `in ${diffHours}h`
return nextCheck.toLocaleString()
}
async function toggleEnabled(subscription) { async function toggleEnabled(subscription) {
try { try {
await subscriptionsStore.updateSubscription(subscription.id, { enabled: !subscription.enabled }) await subscriptionsStore.updateSubscription(subscription.id, { enabled: !subscription.enabled })
@@ -493,6 +567,116 @@ async function deleteSource() {
notifications.error(`Failed to delete: ${error.message}`) notifications.error(`Failed to delete: ${error.message}`)
} }
} }
// Bulk actions
async function bulkEnable(enabled) {
try {
for (const id of selectedIds.value) {
await subscriptionsStore.updateSubscription(id, { enabled })
}
notifications.success(`${selectedIds.value.length} subscriptions ${enabled ? 'enabled' : 'disabled'}`)
selectedIds.value = []
loadSubscriptions()
} catch (error) {
notifications.error(`Failed to update: ${error.message}`)
}
}
async function bulkDelete() {
if (!confirm(`Delete ${selectedIds.value.length} subscriptions? This cannot be undone.`)) return
try {
for (const id of selectedIds.value) {
await subscriptionsStore.deleteSubscription(id)
}
notifications.success(`${selectedIds.value.length} subscriptions deleted`)
selectedIds.value = []
loadSubscriptions()
} catch (error) {
notifications.error(`Failed to delete: ${error.message}`)
}
}
// Import/Export
function exportSubscriptions() {
const data = {
version: 1,
exported_at: new Date().toISOString(),
subscriptions: subscriptionsStore.subscriptions.map(sub => ({
name: sub.name,
description: sub.description,
priority: sub.priority,
enabled: sub.enabled,
sources: sub.sources?.map(s => ({
platform: s.platform,
url: s.url,
enabled: s.enabled,
check_interval: s.check_interval,
})) || [],
})),
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `subscriptions-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
notifications.success(`Exported ${data.subscriptions.length} subscriptions`)
}
function triggerImport() {
importInput.value.click()
}
async function handleImport(event) {
const file = event.target.files[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text)
if (!data.subscriptions || !Array.isArray(data.subscriptions)) {
throw new Error('Invalid format: missing subscriptions array')
}
let imported = 0
for (const sub of data.subscriptions) {
try {
// Create subscription
const created = await subscriptionsStore.createSubscription({
name: sub.name,
description: sub.description,
priority: sub.priority || 0,
enabled: sub.enabled !== false,
})
// Add sources
if (sub.sources && created.id) {
for (const source of sub.sources) {
await subscriptionsStore.addSource(created.id, {
platform: source.platform,
url: source.url,
enabled: source.enabled !== false,
check_interval: source.check_interval,
})
}
}
imported++
} catch (e) {
console.error(`Failed to import ${sub.name}:`, e)
}
}
notifications.success(`Imported ${imported} of ${data.subscriptions.length} subscriptions`)
loadSubscriptions()
} catch (error) {
notifications.error(`Import failed: ${error.message}`)
}
// Reset input
event.target.value = ''
}
</script> </script>
<style scoped> <style scoped>