tuning ui and adding adaptive themeing to extension
This commit is contained in:
+7
-2
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -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
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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-card-text>
|
<v-btn
|
||||||
<v-list v-if="stats?.by_platform">
|
color="primary"
|
||||||
<v-list-item
|
prepend-icon="mdi-refresh"
|
||||||
v-for="(count, platform) in stats.by_platform"
|
:loading="checkingAll"
|
||||||
:key="platform"
|
@click="checkAllSources"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
Check All Sources
|
||||||
<v-icon :color="getPlatformColor(platform)">
|
</v-btn>
|
||||||
{{ getPlatformIcon(platform) }}
|
<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>
|
</v-icon>
|
||||||
</template>
|
Running
|
||||||
<v-list-item-title class="text-capitalize">
|
<v-chip v-if="runningDownloads.length" size="small" color="info" class="ml-2">
|
||||||
{{ platform }}
|
{{ 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-list v-if="runningDownloads.length" density="compact">
|
||||||
|
<v-list-item v-for="dl in runningDownloads" :key="dl.id">
|
||||||
|
<v-list-item-title class="text-body-2">
|
||||||
|
{{ truncate(dl.url, 30) }}
|
||||||
</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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user