mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a1a8ffa50 | |||
| d70f6c9957 | |||
| 04dbb05a6e | |||
| cb9f1b81bc | |||
| 5d8ac2f73d | |||
| c146383735 | |||
| 3820b9b21d | |||
| 20798b3dc0 | |||
| 3f8180a246 | |||
| c97df7798a | |||
| c0096cda1a | |||
| 98a2cc2813 | |||
| 4df49633b4 | |||
| 6f0d71ee9b | |||
| a145dc9877 | |||
| 89422fc77a | |||
| e40e77c8fe | |||
| 7addb1dc70 | |||
| eca24e46a8 | |||
| b1561a8311 | |||
| 20c8fb0898 | |||
| b013538b63 | |||
| bceb13f4dd | |||
| 6b83e5dac1 | |||
| af0eaceb98 | |||
| 1d25f7f7b2 | |||
| 4e84887d33 |
@@ -53,6 +53,24 @@ services:
|
||||
# CONFIG_ENCRYPTION_KEY: "your-custom-encryption-key-here"
|
||||
# POSTGRES_PASSWORD: "your-custom-postgres-password-here"
|
||||
|
||||
# ========================================================================
|
||||
# OPTIONAL: External PostgreSQL and Redis
|
||||
# ========================================================================
|
||||
# To use external PostgreSQL or Redis instances instead of the internal ones,
|
||||
# uncomment and configure the appropriate URL(s):
|
||||
#
|
||||
# External PostgreSQL example:
|
||||
# DATABASE_URL: "postgresql://username:password@postgres.example.com:5432/readmeabook"
|
||||
#
|
||||
# External Redis example:
|
||||
# REDIS_URL: "redis://redis.example.com:6379"
|
||||
# REDIS_URL: "redis://:password@redis.example.com:6379" # With password
|
||||
#
|
||||
# Note: When using external services:
|
||||
# - The internal PostgreSQL/Redis will NOT start (smart detection)
|
||||
# - You do NOT need to mount ./pgdata or ./redis volumes
|
||||
# - Ensure your external services are accessible from the container
|
||||
|
||||
# ========================================================================
|
||||
# OPTIONAL: Rootless Podman Support
|
||||
# ========================================================================
|
||||
@@ -71,6 +89,9 @@ services:
|
||||
# PLEX_CLIENT_IDENTIFIER: "readmeabook-custom-id"
|
||||
# PLEX_PRODUCT_NAME: "ReadMeABook"
|
||||
# LOG_LEVEL: "info"
|
||||
# DISABLE_LOCAL_LOGIN: "true" # Set to "true" to disable local login (force OAuth)
|
||||
# ALLOW_WEAK_PASSWORD: "true" # Set to "true" to remove minimum password length requirement
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# IMPORTANT: Public URL Configuration (Required for OAuth)
|
||||
|
||||
@@ -53,14 +53,75 @@ start_server() {
|
||||
start_server
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "[App] Waiting for server to be ready..."
|
||||
sleep 5
|
||||
# =============================================================================
|
||||
# WAIT FOR SERVER READINESS
|
||||
# =============================================================================
|
||||
# The health endpoint (/api/health) checks both the Next.js server AND database
|
||||
# connectivity. We must wait for both before initializing scheduled jobs.
|
||||
|
||||
# Initialize application services (creates default scheduled jobs)
|
||||
echo "[App] Initializing application services..."
|
||||
curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
|
||||
HEALTH_URL="http://localhost:3030/api/health"
|
||||
INIT_URL="http://localhost:3030/api/init"
|
||||
READY_TIMEOUT=${APP_READY_TIMEOUT:-60}
|
||||
INIT_RETRIES=${APP_INIT_RETRIES:-5}
|
||||
|
||||
echo "[App] Server ready with PID $SERVER_PID"
|
||||
echo "[App] Waiting for server to be ready (timeout: ${READY_TIMEOUT}s)..."
|
||||
|
||||
READY=false
|
||||
for i in $(seq 1 "$READY_TIMEOUT"); do
|
||||
# Check if the server process is still alive
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "[App] ERROR: Server process (PID $SERVER_PID) exited unexpectedly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
READY=true
|
||||
echo "[App] Server is healthy (took ${i}s)"
|
||||
break
|
||||
fi
|
||||
|
||||
# Log progress every 10 seconds
|
||||
if [ $((i % 10)) -eq 0 ]; then
|
||||
echo "[App] Still waiting for server... (${i}/${READY_TIMEOUT}s)"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$READY" = "false" ]; then
|
||||
echo "[App] ERROR: Server did not become healthy within ${READY_TIMEOUT}s"
|
||||
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
|
||||
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
|
||||
else
|
||||
# =========================================================================
|
||||
# INITIALIZE APPLICATION SERVICES
|
||||
# =========================================================================
|
||||
# Creates default scheduled jobs, runs credential migration, etc.
|
||||
# Retry with backoff to handle transient failures during startup.
|
||||
|
||||
echo "[App] Initializing application services..."
|
||||
|
||||
INIT_SUCCESS=false
|
||||
for attempt in $(seq 1 "$INIT_RETRIES"); do
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$INIT_URL" 2>/dev/null) || HTTP_CODE="000"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
INIT_SUCCESS=true
|
||||
echo "[App] Services initialized successfully"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "[App] Init attempt $attempt/$INIT_RETRIES failed (HTTP $HTTP_CODE), retrying in ${attempt}s..."
|
||||
sleep "$attempt"
|
||||
done
|
||||
|
||||
if [ "$INIT_SUCCESS" = "false" ]; then
|
||||
echo "[App] ERROR: Failed to initialize services after $INIT_RETRIES attempts"
|
||||
echo "[App] Scheduled jobs may be missing - check application logs for details"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[App] Server running with PID $SERVER_PID"
|
||||
|
||||
# Verify the process is running with correct UID:GID (for debugging)
|
||||
if [ -f "/proc/$SERVER_PID/status" ]; then
|
||||
|
||||
+171
-99
@@ -157,67 +157,104 @@ export PLEX_PRODUCT_NAME="${PLEX_PRODUCT_NAME:-ReadMeABook}"
|
||||
export LOG_LEVEL="${LOG_LEVEL:-info}"
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZE POSTGRESQL
|
||||
# DETECT EXTERNAL SERVICES
|
||||
# ============================================================================
|
||||
PGDATA="/var/lib/postgresql/data"
|
||||
PG_WAS_EMPTY=0
|
||||
# Check if user provided external DATABASE_URL or REDIS_URL
|
||||
USE_EXTERNAL_POSTGRES=false
|
||||
USE_EXTERNAL_REDIS=false
|
||||
|
||||
# Ensure correct ownership of data directories (critical for bind mounts)
|
||||
echo "🔧 Setting up directory permissions..."
|
||||
|
||||
# PostgreSQL directories - owned by postgres user, group accessible
|
||||
if ! chown -R postgres:postgres "$PGDATA" /var/run/postgresql 2>/dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Failed to set ownership on PostgreSQL directories"
|
||||
echo ""
|
||||
echo " This usually happens when using bind mounts on incompatible filesystems."
|
||||
echo ""
|
||||
echo " Common causes:"
|
||||
echo " - WSL2: Project on Windows filesystem (/mnt/c/...)"
|
||||
echo " - NFS/CIFS: Mount without proper permission support"
|
||||
echo ""
|
||||
echo " Solutions:"
|
||||
echo ""
|
||||
echo " 1. Use Docker named volumes (recommended for WSL2):"
|
||||
echo " In docker-compose.yml, change:"
|
||||
echo " - ./pgdata:/var/lib/postgresql/data"
|
||||
echo " To:"
|
||||
echo " - pgdata:/var/lib/postgresql/data"
|
||||
echo " Then add at bottom:"
|
||||
echo " volumes:"
|
||||
echo " pgdata:"
|
||||
echo ""
|
||||
echo " 2. Move project to Linux filesystem (WSL2):"
|
||||
echo " mkdir -p ~/readmeabook && cd ~/readmeabook"
|
||||
echo " # Copy docker-compose.yml and restart"
|
||||
echo ""
|
||||
echo " 3. Pre-create directories with correct ownership:"
|
||||
echo " mkdir -p pgdata redis config cache"
|
||||
echo " # Let Docker create them on first run"
|
||||
echo ""
|
||||
exit 1
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p')
|
||||
if [ "$DB_HOST" != "127.0.0.1" ] && [ "$DB_HOST" != "localhost" ]; then
|
||||
USE_EXTERNAL_POSTGRES=true
|
||||
echo "ℹ️ External PostgreSQL detected at $DB_HOST"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$PGID" ]; then
|
||||
# With PUID/PGID: Use 750 (owner rwx, group rx) for PostgreSQL data
|
||||
# This allows the PGID group to read PostgreSQL files if needed
|
||||
chmod 750 "$PGDATA"
|
||||
chmod 775 /var/run/postgresql
|
||||
if [ -n "$REDIS_URL" ]; then
|
||||
# Extract host from REDIS_URL - handles both redis://host:port and redis://:password@host:port
|
||||
if echo "$REDIS_URL" | grep -q '@'; then
|
||||
REDIS_HOST=$(echo "$REDIS_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p')
|
||||
else
|
||||
REDIS_HOST=$(echo "$REDIS_URL" | sed -n 's|redis://\([^:/]*\).*|\1|p')
|
||||
fi
|
||||
if [ -n "$REDIS_HOST" ] && [ "$REDIS_HOST" != "127.0.0.1" ] && [ "$REDIS_HOST" != "localhost" ]; then
|
||||
USE_EXTERNAL_REDIS=true
|
||||
echo "ℹ️ External Redis detected at $REDIS_HOST"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZE POSTGRESQL (only if using internal PostgreSQL)
|
||||
# ============================================================================
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
echo "📦 Configuring internal PostgreSQL..."
|
||||
PGDATA="/var/lib/postgresql/data"
|
||||
PG_WAS_EMPTY=0
|
||||
|
||||
# Ensure correct ownership of data directories (critical for bind mounts)
|
||||
echo "🔧 Setting up directory permissions..."
|
||||
|
||||
# PostgreSQL directories - owned by postgres user, group accessible
|
||||
if ! chown -R postgres:postgres "$PGDATA" /var/run/postgresql 2>/dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Failed to set ownership on PostgreSQL directories"
|
||||
echo ""
|
||||
echo " This usually happens when using bind mounts on incompatible filesystems."
|
||||
echo ""
|
||||
echo " Common causes:"
|
||||
echo " - WSL2: Project on Windows filesystem (/mnt/c/...)"
|
||||
echo " - NFS/CIFS: Mount without proper permission support"
|
||||
echo ""
|
||||
echo " Solutions:"
|
||||
echo ""
|
||||
echo " 1. Use Docker named volumes (recommended for WSL2):"
|
||||
echo " In docker-compose.yml, change:"
|
||||
echo " - ./pgdata:/var/lib/postgresql/data"
|
||||
echo " To:"
|
||||
echo " - pgdata:/var/lib/postgresql/data"
|
||||
echo " Then add at bottom:"
|
||||
echo " volumes:"
|
||||
echo " pgdata:"
|
||||
echo ""
|
||||
echo " 2. Move project to Linux filesystem (WSL2):"
|
||||
echo " mkdir -p ~/readmeabook && cd ~/readmeabook"
|
||||
echo " # Copy docker-compose.yml and restart"
|
||||
echo ""
|
||||
echo " 3. Pre-create directories with correct ownership:"
|
||||
echo " mkdir -p pgdata redis config cache"
|
||||
echo " # Let Docker create them on first run"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$PGID" ]; then
|
||||
# With PUID/PGID: Use 750 (owner rwx, group rx) for PostgreSQL data
|
||||
# This allows the PGID group to read PostgreSQL files if needed
|
||||
chmod 750 "$PGDATA"
|
||||
chmod 775 /var/run/postgresql
|
||||
else
|
||||
# Without PUID/PGID: Use strict 700 permissions (owner only)
|
||||
chmod 700 "$PGDATA"
|
||||
chmod 775 /var/run/postgresql
|
||||
fi
|
||||
else
|
||||
# Without PUID/PGID: Use strict 700 permissions (owner only)
|
||||
chmod 700 "$PGDATA"
|
||||
chmod 775 /var/run/postgresql
|
||||
echo "⏭️ Skipping internal PostgreSQL setup (using external database)"
|
||||
fi
|
||||
|
||||
# Redis directory - owned by redis user (remapped to PUID:PGID if set)
|
||||
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Failed to set ownership on Redis directory"
|
||||
echo " See solutions above for PostgreSQL directories"
|
||||
echo ""
|
||||
exit 1
|
||||
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
|
||||
if ! chown -R redis:redis /var/lib/redis 2>/dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Failed to set ownership on Redis directory"
|
||||
echo " See solutions above for PostgreSQL directories"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
chmod 770 /var/lib/redis
|
||||
else
|
||||
echo "⏭️ Skipping internal Redis setup (using external Redis)"
|
||||
fi
|
||||
chmod 770 /var/lib/redis
|
||||
|
||||
# App directories - owned by node user (remapped to PUID:PGID if set)
|
||||
# These need group write permissions for shared access
|
||||
@@ -232,18 +269,20 @@ chmod 775 /app/config /app/cache
|
||||
|
||||
echo "✅ Directory permissions configured"
|
||||
|
||||
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||
PG_WAS_EMPTY=1
|
||||
echo "📦 Initializing PostgreSQL database..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA"
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
# Only initialize/setup PostgreSQL if using internal instance
|
||||
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||
PG_WAS_EMPTY=1
|
||||
echo "📦 Initializing PostgreSQL database..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/initdb -D $PGDATA"
|
||||
|
||||
# Configure PostgreSQL for local access
|
||||
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
|
||||
echo "host all all ::1/128 trust" >> "$PGDATA/pg_hba.conf"
|
||||
echo "local all all trust" >> "$PGDATA/pg_hba.conf"
|
||||
# Configure PostgreSQL for local access
|
||||
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
|
||||
echo "host all all ::1/128 trust" >> "$PGDATA/pg_hba.conf"
|
||||
echo "local all all trust" >> "$PGDATA/pg_hba.conf"
|
||||
|
||||
# Update postgresql.conf for performance
|
||||
cat >> "$PGDATA/postgresql.conf" <<EOF
|
||||
# Update postgresql.conf for performance
|
||||
cat >> "$PGDATA/postgresql.conf" <<EOF
|
||||
listen_addresses = '127.0.0.1'
|
||||
max_connections = 100
|
||||
shared_buffers = 128MB
|
||||
@@ -254,31 +293,31 @@ log_destination = 'stderr'
|
||||
logging_collector = off
|
||||
EOF
|
||||
|
||||
echo "✅ PostgreSQL initialized"
|
||||
else
|
||||
echo "✅ PostgreSQL data directory already exists"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE
|
||||
# ============================================================================
|
||||
echo "🔧 Starting PostgreSQL for setup..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA -w start -o '-c listen_addresses=127.0.0.1'"
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
for i in {1..30}; do
|
||||
if su - postgres -c "/usr/lib/postgresql/16/bin/pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; then
|
||||
echo "✅ PostgreSQL is ready"
|
||||
break
|
||||
echo "✅ PostgreSQL initialized"
|
||||
else
|
||||
echo "✅ PostgreSQL data directory already exists"
|
||||
fi
|
||||
echo "⏳ Waiting for PostgreSQL to be ready... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Always ensure user and database exist (safe due to IF NOT EXISTS checks)
|
||||
# This handles cases where data directory exists but user/database don't
|
||||
echo "👤 Ensuring database user and database exist..."
|
||||
su - postgres -c "psql -h 127.0.0.1 -U postgres" <<EOF
|
||||
# ========================================================================
|
||||
# START POSTGRESQL TEMPORARILY TO CREATE USER/DATABASE
|
||||
# ========================================================================
|
||||
echo "🔧 Starting PostgreSQL for setup..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA -w start -o '-c listen_addresses=127.0.0.1'"
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
for i in {1..30}; do
|
||||
if su - postgres -c "/usr/lib/postgresql/16/bin/pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; then
|
||||
echo "✅ PostgreSQL is ready"
|
||||
break
|
||||
fi
|
||||
echo "⏳ Waiting for PostgreSQL to be ready... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Always ensure user and database exist (safe due to IF NOT EXISTS checks)
|
||||
# This handles cases where data directory exists but user/database don't
|
||||
echo "👤 Ensuring database user and database exist..."
|
||||
su - postgres -c "psql -h 127.0.0.1 -U postgres" <<EOF
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '$POSTGRES_USER') THEN
|
||||
@@ -296,19 +335,36 @@ GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;
|
||||
ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER;
|
||||
EOF
|
||||
|
||||
if [ "$PG_WAS_EMPTY" -eq 1 ]; then
|
||||
echo "✅ Database initialized and setup complete"
|
||||
else
|
||||
echo "✅ Database user and permissions verified"
|
||||
if [ "$PG_WAS_EMPTY" -eq 1 ]; then
|
||||
echo "✅ Database initialized and setup complete"
|
||||
else
|
||||
echo "✅ Database user and permissions verified"
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# SET ENVIRONMENT VARIABLES FOR APP
|
||||
# ============================================================================
|
||||
# URL-encode the password to handle special characters
|
||||
ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD")
|
||||
export DATABASE_URL="postgresql://$POSTGRES_USER:$ENCODED_PASSWORD@127.0.0.1:5432/$POSTGRES_DB"
|
||||
export REDIS_URL="redis://127.0.0.1:6379"
|
||||
# Set DATABASE_URL and REDIS_URL based on whether we're using internal or external services
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
# URL-encode the password to handle special characters
|
||||
ENCODED_PASSWORD=$(urlencode "$POSTGRES_PASSWORD")
|
||||
export DATABASE_URL="postgresql://$POSTGRES_USER:$ENCODED_PASSWORD@127.0.0.1:5432/$POSTGRES_DB"
|
||||
echo "✅ Using internal PostgreSQL (127.0.0.1:5432)"
|
||||
else
|
||||
# DATABASE_URL already set by user - do not modify
|
||||
echo "✅ Using external DATABASE_URL: $(echo "$DATABASE_URL" | sed 's|//.*@|//***@|')"
|
||||
fi
|
||||
|
||||
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
|
||||
export REDIS_URL="redis://127.0.0.1:6379"
|
||||
echo "✅ Using internal Redis (127.0.0.1:6379)"
|
||||
else
|
||||
# REDIS_URL already set by user - do not modify
|
||||
echo "✅ Using external REDIS_URL: $(echo "$REDIS_URL" | sed 's|//.*@|//***@|')"
|
||||
fi
|
||||
|
||||
export NODE_ENV="production"
|
||||
export PORT="3030"
|
||||
export HOSTNAME="0.0.0.0"
|
||||
@@ -318,6 +374,8 @@ export HOSTNAME="0.0.0.0"
|
||||
cat > /etc/environment <<EOF
|
||||
DATABASE_URL=$DATABASE_URL
|
||||
REDIS_URL=$REDIS_URL
|
||||
USE_EXTERNAL_POSTGRES=$USE_EXTERNAL_POSTGRES
|
||||
USE_EXTERNAL_REDIS=$USE_EXTERNAL_REDIS
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET
|
||||
CONFIG_ENCRYPTION_KEY=$CONFIG_ENCRYPTION_KEY
|
||||
@@ -335,15 +393,21 @@ EOF
|
||||
echo "✅ Environment configured"
|
||||
|
||||
# ============================================================================
|
||||
# RUN PRISMA MIGRATIONS (while PostgreSQL is still running)
|
||||
# RUN PRISMA MIGRATIONS
|
||||
# ============================================================================
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "true" ]; then
|
||||
echo "⚠️ Running schema sync against EXTERNAL database - prisma db push --accept-data-loss"
|
||||
echo " This runs on every container start. Ensure your external database is backed up."
|
||||
fi
|
||||
echo "🔄 Running Prisma migrations..."
|
||||
cd /app
|
||||
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
|
||||
|
||||
# Stop PostgreSQL (supervisord will start it)
|
||||
echo "🔧 Stopping temporary PostgreSQL instance..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
|
||||
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
echo "🔧 Stopping temporary PostgreSQL instance..."
|
||||
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $PGDATA stop -m fast"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# DISPLAY STARTUP INFO
|
||||
@@ -361,8 +425,16 @@ if [ "$POSTGRES_PASSWORD" = "$(generate_secret)" ]; then
|
||||
fi
|
||||
echo ""
|
||||
echo "📊 Services starting:"
|
||||
echo " - PostgreSQL (internal, user=postgres)"
|
||||
echo " - Redis (internal, UID:GID=${PUID:-102}:${PGID:-102})"
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
echo " - PostgreSQL (internal, 127.0.0.1:5432)"
|
||||
else
|
||||
echo " - PostgreSQL (external - local instance disabled)"
|
||||
fi
|
||||
if [ "$USE_EXTERNAL_REDIS" = "false" ]; then
|
||||
echo " - Redis (internal, 127.0.0.1:6379, UID:GID=${PUID:-102}:${PGID:-102})"
|
||||
else
|
||||
echo " - Redis (external - local instance disabled)"
|
||||
fi
|
||||
echo " - Next.js App (port 3030, UID:GID=${PUID:-1000}:${PGID:-1000})"
|
||||
if [ "${ROOTLESS_CONTAINER}" = "true" ]; then
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# PostgreSQL startup wrapper for unified container
|
||||
# Checks USE_EXTERNAL_POSTGRES flag (set by entrypoint) to decide whether
|
||||
# to start the local instance or sleep to keep supervisord happy.
|
||||
|
||||
set -e
|
||||
|
||||
# Load environment from /etc/environment (set by entrypoint)
|
||||
if [ -f /etc/environment ]; then
|
||||
set -a
|
||||
source /etc/environment
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "true" ]; then
|
||||
echo "[PostgreSQL] External database configured - skipping local instance"
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
echo "[PostgreSQL] Starting local PostgreSQL server..."
|
||||
exec /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
|
||||
@@ -1,5 +1,8 @@
|
||||
#!/bin/bash
|
||||
# Redis startup wrapper for unified container
|
||||
# Checks USE_EXTERNAL_REDIS flag (set by entrypoint) to decide whether
|
||||
# to start the local instance or sleep to keep supervisord happy.
|
||||
#
|
||||
# Uses gosu to ensure correct PUID:PGID for file operations
|
||||
#
|
||||
# Supports:
|
||||
@@ -15,11 +18,17 @@ if [ -f /etc/environment ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ "$USE_EXTERNAL_REDIS" = "true" ]; then
|
||||
echo "[Redis] External Redis configured - skipping local instance"
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
echo "[Redis] Starting local Redis server..."
|
||||
|
||||
# Get PUID/PGID (default to redis user's current IDs if not set)
|
||||
PUID=${PUID:-$(id -u redis)}
|
||||
PGID=${PGID:-$(id -g redis)}
|
||||
|
||||
echo "[Redis] Starting Redis server..."
|
||||
echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -7,7 +7,7 @@ loglevel=info
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:postgresql]
|
||||
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
|
||||
command=/app/postgres-start.sh
|
||||
user=postgres
|
||||
autostart=true
|
||||
autorestart=true
|
||||
|
||||
@@ -115,6 +115,11 @@ COPY --chown=root:root docker/unified/redis-start.sh /app/redis-start.sh
|
||||
# Convert line endings and make executable
|
||||
RUN sed -i 's/\r$//' /app/redis-start.sh && chmod +x /app/redis-start.sh
|
||||
|
||||
# Copy postgres startup wrapper
|
||||
COPY --chown=root:root docker/unified/postgres-start.sh /app/postgres-start.sh
|
||||
# Convert line endings and make executable
|
||||
RUN sed -i 's/\r$//' /app/postgres-start.sh && chmod +x /app/postgres-start.sh
|
||||
|
||||
# Expose app port
|
||||
EXPOSE 3030
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ docker-compose logs -f app
|
||||
## 📊 Feature Highlights
|
||||
|
||||
### AI-Powered Recommendations
|
||||
- **Providers:** OpenAI (GPT-4o+) or Claude (Sonnet 4.5, Opus 4, Haiku)
|
||||
- **Providers:** OpenAI (GPT-4+) or Claude (dynamically fetched from Anthropic Models API)
|
||||
- **Personalization:** Based on your Plex library + swipe history
|
||||
- **Context:** Max 50 books (40 library + 10 swipes)
|
||||
- **Filtering:** Excludes books already in library, already requested, or already swiped
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Notification System
|
||||
|
||||
**Status:** ✅ Implemented | Extensible notification system with Discord and Pushover support
|
||||
**Status:** ✅ Implemented | Extensible notification system with Discord, ntfy, and Pushover support
|
||||
|
||||
## Overview
|
||||
Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search.
|
||||
|
||||
## Key Details
|
||||
- **Backends:** Discord (webhooks), Pushover (API)
|
||||
- **Events:** request_pending_approval, request_approved, request_available, request_error
|
||||
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys)
|
||||
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
|
||||
- **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported
|
||||
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
|
||||
- **Delivery:** Async via Bull job queue (priority 5)
|
||||
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
|
||||
|
||||
@@ -17,7 +17,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
|
||||
```prisma
|
||||
model NotificationBackend {
|
||||
id String @id @default(uuid())
|
||||
type String // 'discord' | 'pushover'
|
||||
type String // 'apprise' | 'discord' | 'ntfy' | 'pushover'
|
||||
name String // User-friendly label
|
||||
config Json // Encrypted sensitive values
|
||||
events Json // Array of subscribed events
|
||||
@@ -33,8 +33,15 @@ model NotificationBackend {
|
||||
|-------|---------|------------------------|
|
||||
| request_pending_approval | User creates request | Request needs admin approval |
|
||||
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
|
||||
| request_available | Plex/ABS scan completes | Audiobook available in library |
|
||||
| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
|
||||
| request_error | Download/import fails | Request failed at any stage |
|
||||
| issue_reported | User reports issue | User reports problem with available audiobook |
|
||||
|
||||
**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
|
||||
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
|
||||
- `request_available` + `requestType: 'ebook'` → "Ebook Available"
|
||||
- `request_available` + no requestType → "Request Available" (fallback)
|
||||
- Use `getEventTitle(event, requestType?)` to resolve titles in providers
|
||||
|
||||
## Notification Triggers
|
||||
|
||||
@@ -59,18 +66,28 @@ model NotificationBackend {
|
||||
- Approve (with or without pre-selected torrent): After job triggered → request_approved
|
||||
- Deny: No notification
|
||||
|
||||
**Request Available (processors: scan-plex, plex-recently-added)**
|
||||
- After `status: 'available'` update → request_available
|
||||
**Audiobook Available (processors: scan-plex, plex-recently-added)**
|
||||
- After `status: 'available'` update → request_available (requestType: 'audiobook')
|
||||
- Includes user info in query (plexUsername)
|
||||
|
||||
**Ebook Available (processor: organize-files)**
|
||||
- After ebook `status: 'downloaded'` (terminal) → request_available (requestType: 'ebook')
|
||||
- Ebooks don't transition to 'available' via Plex matching
|
||||
|
||||
**Request Error (processors: monitor-download, organize-files)**
|
||||
- After `status: 'failed'` or `status: 'warn'` update → request_error
|
||||
- Includes error message in payload
|
||||
|
||||
**Issue Reported (reported-issue.service.ts)**
|
||||
- After user reports issue with available audiobook → issue_reported
|
||||
- Payload: issue ID (as requestId), book title/author, reporter username, reason (as message)
|
||||
|
||||
## Configuration Encryption
|
||||
|
||||
**Encrypted Values:**
|
||||
- Apprise: `urls`, `authToken`
|
||||
- Discord: `webhookUrl`
|
||||
- ntfy: `accessToken`
|
||||
- Pushover: `userKey`, `appToken`
|
||||
|
||||
**Pattern:** `iv:authTag:encryptedData` (base64)
|
||||
@@ -81,12 +98,27 @@ model NotificationBackend {
|
||||
|
||||
## Message Formatting
|
||||
|
||||
**Apprise (JSON via Apprise API):**
|
||||
- Type: info (pending), success (approved/available), failure (error)
|
||||
- Modes: Stateless (send URLs directly) or Stateful (use persistent configKey, optional tag filter)
|
||||
- Endpoint: `{serverUrl}/notify/` (stateless) or `{serverUrl}/notify/{configKey}` (stateful)
|
||||
- Auth: Optional Bearer token via `authToken` config field
|
||||
- Format: Event title + book details + user + error (if applicable)
|
||||
|
||||
**Discord (Rich Embeds):**
|
||||
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error)
|
||||
- Fields: Title, Author, Requested By, Error (if applicable)
|
||||
- Footer: Request ID
|
||||
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error, orange=issue)
|
||||
- Fields: Title, Author, Requested/Reported By, Error/Reason (if applicable)
|
||||
- Footer: Request/Issue ID
|
||||
- Timestamp: Event time
|
||||
|
||||
**ntfy (JSON Publishing to Base URL):**
|
||||
- Endpoint: POST to base `serverUrl` (default: https://ntfy.sh), topic in JSON body
|
||||
- Tags: mailbox_with_mail, white_check_mark, tada, x, triangular_flag_on_post (rendered as emojis by ntfy)
|
||||
- Priority: Default (3) for pending/approved, High (4) for available/error
|
||||
- Format: Event title + book details + user + error (if applicable)
|
||||
- Auth: Optional Bearer token via `accessToken` config field
|
||||
- Server: Configurable `serverUrl` (default: https://ntfy.sh)
|
||||
|
||||
**Pushover (Plain Text with Emojis):**
|
||||
- Emojis: 📬 📬 🎉 ❌
|
||||
- Priority: Normal (0) for pending/approved, High (1) for available/error
|
||||
@@ -126,7 +158,7 @@ model NotificationBackend {
|
||||
**Modal Features:**
|
||||
- Type-first selection (user clicks "Add Discord" or "Add Pushover")
|
||||
- Password inputs for sensitive values
|
||||
- Event subscription checkboxes (4 events, default: available + error)
|
||||
- Event subscription checkboxes (5 events, default: available + error)
|
||||
- Test button (sends synchronous test notification)
|
||||
- Save button (validates and creates/updates backend)
|
||||
|
||||
@@ -144,6 +176,7 @@ model NotificationBackend {
|
||||
author: string,
|
||||
userName: string,
|
||||
message?: string,
|
||||
requestType?: string, // 'audiobook' | 'ebook' — drives type-specific titles
|
||||
timestamp: Date
|
||||
}
|
||||
```
|
||||
@@ -152,28 +185,67 @@ model NotificationBackend {
|
||||
- Calls NotificationService.sendNotification()
|
||||
- Non-blocking error handling (logs but doesn't throw)
|
||||
|
||||
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)`
|
||||
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?, requestType?)`
|
||||
|
||||
## Architecture
|
||||
|
||||
**Provider Pattern:** `INotificationProvider` interface + registry (matches `IAuthProvider` pattern)
|
||||
|
||||
```
|
||||
src/lib/services/notification/
|
||||
INotificationProvider.ts # Interface + shared types
|
||||
notification.service.ts # Core service with registry
|
||||
index.ts # Re-exports
|
||||
providers/
|
||||
apprise.provider.ts # Apprise API (100+ services)
|
||||
discord.provider.ts # Discord webhook
|
||||
ntfy.provider.ts # ntfy API
|
||||
pushover.provider.ts # Pushover API
|
||||
```
|
||||
|
||||
**Registry:** Module-level `Map<string, INotificationProvider>` with `registerProvider()` / `getProvider()`
|
||||
|
||||
**INotificationProvider interface:**
|
||||
- `type: string` — provider identifier (registry key)
|
||||
- `sensitiveFields: string[]` — fields needing encryption/masking
|
||||
- `metadata: ProviderMetadata` — self-describing UI/validation metadata
|
||||
- `send(config, payload): Promise<void>` — receives decrypted config
|
||||
|
||||
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
|
||||
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
|
||||
|
||||
**Helper functions (notification.service.ts):**
|
||||
- `getRegisteredProviderTypes(): string[]` — all registered type keys
|
||||
- `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers
|
||||
|
||||
**Helper functions (notification-events.ts):**
|
||||
- `getEventMeta(event)` — raw event metadata (label, title, emoji, severity, priority)
|
||||
- `getEventTitle(event, requestType?)` — resolved title (checks `titleByRequestType` first, falls back to `title`)
|
||||
- `getEventLabel(event)` — human-readable label for UI
|
||||
|
||||
**API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only)
|
||||
|
||||
## Extensibility
|
||||
|
||||
**Adding New Backend (e.g., Email):**
|
||||
1. Add 'email' to NotificationBackendType enum
|
||||
2. Create EmailConfig interface
|
||||
3. Add encryption logic for smtpPassword
|
||||
4. Implement sendEmail() method in NotificationService
|
||||
5. Add email card to type selector (green "E" badge)
|
||||
6. Add email form fields to modal
|
||||
**Adding New Backend (2 steps):**
|
||||
1. Create `providers/email.provider.ts` implementing `INotificationProvider`:
|
||||
- Set `type = 'email'`, `sensitiveFields = ['smtpPassword']`
|
||||
- Set `metadata` with displayName, description, iconLabel, iconColor, configFields
|
||||
- Implement `send()` with email-specific logic
|
||||
2. Register in `notification.service.ts`: `registerProvider(new EmailProvider())` + re-export from `index.ts`
|
||||
|
||||
No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata.
|
||||
|
||||
**Adding New Event (e.g., download_complete):**
|
||||
1. Add 'download_complete' to NotificationEvent enum
|
||||
2. Add to event labels in UI
|
||||
3. Add trigger point in processor
|
||||
4. Add message formatting in Discord/Pushover formatters
|
||||
1. Add entry to `NOTIFICATION_EVENTS` in `notification-events.ts` (label, title, emoji, severity, priority)
|
||||
2. Optionally add `titleByRequestType` for type-specific titles
|
||||
3. Add trigger point in processor, passing `requestType` if relevant
|
||||
4. Providers auto-resolve titles via `getEventTitle()` — no per-provider changes needed
|
||||
|
||||
## Tech Stack
|
||||
- Bull (job queue)
|
||||
- Node.js crypto (AES-256-GCM encryption)
|
||||
- Discord webhooks, Pushover API
|
||||
- Apprise API, Discord webhooks, ntfy API, Pushover API
|
||||
- React (UI), Tailwind CSS (styling)
|
||||
|
||||
## Related
|
||||
|
||||
@@ -200,32 +200,23 @@ export async function POST(req: NextRequest) {
|
||||
.map((m: any) => ({ id: m.id, name: m.id }));
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
// Claude: Hardcoded list (Anthropic doesn't have a models API endpoint)
|
||||
models = [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
];
|
||||
|
||||
// Test connection with a simple API call
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||
const response = await fetch('https://api.anthropic.com/v1/models?limit=1000', {
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 10,
|
||||
messages: [{ role: 'user', content: 'Hi' }]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
models = data.data.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.display_name || m.id,
|
||||
}));
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI provider globally. Users swipe through recommendations based on their individual Plex library + swipe history. Right swipe creates request, left rejects, up dismisses.
|
||||
|
||||
## Key Details
|
||||
- **AI Providers:** OpenAI (GPT-4o+), Claude (Sonnet 4.5, Opus 4, Haiku)
|
||||
- **AI Providers:** OpenAI (GPT-4+), Claude (dynamically fetched from Anthropic Models API)
|
||||
- **Configuration:** Global admin-managed (provider, model, API key), per-user preferences (library scope, custom prompt)
|
||||
- **Personalization:** Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences
|
||||
- **Library Scopes (per-user):**
|
||||
|
||||
@@ -32,6 +32,7 @@ Configurable Audible region for accurate metadata matching across different inte
|
||||
- Australia (`au`) - `audible.com.au` (English)
|
||||
- India (`in`) - `audible.in` (English)
|
||||
- Germany (`de`) - `audible.de` (non-English)
|
||||
- Spain (`es`) - `audible.es` (non-English)
|
||||
|
||||
**`isEnglish` Flag:**
|
||||
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
|
||||
|
||||
@@ -208,7 +208,7 @@ async function organize(
|
||||
|
||||
## Fixed Issues ✅
|
||||
|
||||
**1. EPERM errors** - Fixed with `fs.readFile/writeFile` instead of `copyFile`
|
||||
**1. EPERM errors** - Fixed with stream-based copy (`pipeline` + `createReadStream`/`createWriteStream`) instead of `fs.copyFile()` which uses `copy_file_range()` — a syscall that returns EPERM on cross-export NFS4 and some FUSE mounts
|
||||
**2. Immediate deletion** - Changed to copy-only, scheduled cleanup after seeding
|
||||
**3. Files moved not copied** - Now copies to support seeding
|
||||
**4. Single file downloads** - Now supports files directly in downloads folder (not just directories)
|
||||
|
||||
@@ -175,19 +175,19 @@ interface TorrentInfo {
|
||||
}
|
||||
|
||||
type TorrentState =
|
||||
// Core states
|
||||
// Core states (*DL = download phase, *UP = upload/post-download phase)
|
||||
| 'downloading' | 'uploading'
|
||||
| 'stalledDL' | 'stalledUP'
|
||||
| 'pausedDL' | 'pausedUP'
|
||||
| 'queuedDL' | 'queuedUP'
|
||||
| 'checkingDL' | 'checkingUP'
|
||||
| 'stalledDL' | 'stalledUP' // stalledUP → completed (download done)
|
||||
| 'pausedDL' | 'pausedUP' // pausedUP → completed (download done, paused seeding)
|
||||
| 'queuedDL' | 'queuedUP' // queuedUP → completed (download done)
|
||||
| 'checkingDL' | 'checkingUP' // checkingUP → completed (download done, rechecking)
|
||||
| 'error' | 'missingFiles' | 'allocating'
|
||||
// Forced states (user clicked "Force Resume")
|
||||
| 'forcedDL' | 'forcedUP'
|
||||
| 'forcedDL' | 'forcedUP' // forcedUP → completed (download done)
|
||||
// Metadata fetching
|
||||
| 'metaDL' | 'forcedMetaDL'
|
||||
// qBittorrent v5.0+ (renamed paused → stopped)
|
||||
| 'stoppedDL' | 'stoppedUP'
|
||||
| 'stoppedDL' | 'stoppedUP' // stoppedUP → completed (download done)
|
||||
// Other
|
||||
| 'checkingResumeData' | 'moving';
|
||||
```
|
||||
@@ -241,7 +241,13 @@ type TorrentState =
|
||||
- Adding all 8 missing states to `TorrentState` type union
|
||||
- Adding mappings to both `mapState()` (legacy) and `mapStateToDownloadStatus()` (unified interface)
|
||||
- `forcedUP` → `seeding`/`completed` enables monitor to trigger import
|
||||
- `stoppedDL`/`stoppedUP` → `paused` ensures qBittorrent v5.x compatibility
|
||||
- `stoppedDL` → `paused` ensures qBittorrent v5.x compatibility
|
||||
|
||||
**16. pausedUP/stoppedUP mapped as paused instead of completed** - qBittorrent (after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by:
|
||||
- `pausedUP` → `seeding` (unified) / `completed` (legacy) — triggers completion in monitor
|
||||
- `stoppedUP` → `seeding` (unified) / `completed` (legacy) — same fix for qBittorrent v5.x
|
||||
- `pausedDL`/`stoppedDL` remain `paused` — download phase genuinely paused
|
||||
- Key insight: any `*UP` state is post-download; any `*DL` state is pre-completion
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ src/app/admin/settings/
|
||||
|
||||
**PUT /api/admin/settings/audible**
|
||||
- Updates Audible region
|
||||
- Body: `{ region: string }` (one of: us, ca, uk, au, in)
|
||||
- Body: `{ region: string }` (one of: us, ca, uk, au, in, es)
|
||||
- No validation required
|
||||
|
||||
**PUT /api/admin/settings/prowlarr/indexers**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "goodreads_shelves" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"rss_url" TEXT NOT NULL,
|
||||
"last_sync_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "goodreads_shelves_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "goodreads_book_mappings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"goodreads_book_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"audible_asin" TEXT,
|
||||
"cover_url" TEXT,
|
||||
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_search_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "goodreads_book_mappings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goodreads_shelves_user_id_idx" ON "goodreads_shelves"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "goodreads_shelves_user_id_rss_url_key" ON "goodreads_shelves"("user_id", "rss_url");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "goodreads_book_mappings_goodreads_book_id_key" ON "goodreads_book_mappings"("goodreads_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goodreads_book_mappings_goodreads_book_id_idx" ON "goodreads_book_mappings"("goodreads_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goodreads_book_mappings_audible_asin_idx" ON "goodreads_book_mappings"("audible_asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goodreads_shelves" ADD CONSTRAINT "goodreads_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add cached book count and cover URLs to goodreads_shelves for rich UI display
|
||||
ALTER TABLE "goodreads_shelves" ADD COLUMN "book_count" INTEGER;
|
||||
ALTER TABLE "goodreads_shelves" ADD COLUMN "cover_urls" TEXT;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "reported_issues" (
|
||||
"id" TEXT NOT NULL,
|
||||
"audiobook_id" TEXT NOT NULL,
|
||||
"reporter_id" TEXT NOT NULL,
|
||||
"reason" VARCHAR(250) NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"resolved_at" TIMESTAMP(3),
|
||||
"resolved_by_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "reported_issues_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reported_issues_audiobook_id_idx" ON "reported_issues"("audiobook_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reported_issues_reporter_id_idx" ON "reported_issues"("reporter_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reported_issues_status_idx" ON "reported_issues"("status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_audiobook_id_fkey" FOREIGN KEY ("audiobook_id") REFERENCES "audiobooks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_resolved_by_id_fkey" FOREIGN KEY ("resolved_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+74
-1
@@ -64,6 +64,9 @@ model User {
|
||||
requests Request[]
|
||||
bookDateRecommendations BookDateRecommendation[]
|
||||
bookDateSwipes BookDateSwipe[]
|
||||
goodreadsShelves GoodreadsShelf[]
|
||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||
|
||||
@@index([plexId])
|
||||
@@index([role])
|
||||
@@ -173,6 +176,7 @@ model Audiobook {
|
||||
year Int? // Release year extracted from releaseDate
|
||||
series String? // Book series name (e.g., "The Mistborn Saga")
|
||||
seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1")
|
||||
seriesAsin String? @map("series_asin") // Audible series ASIN for linking to series detail page
|
||||
|
||||
// Request tracking
|
||||
status String @default("requested") // requested, downloading, processing, completed, failed
|
||||
@@ -197,7 +201,8 @@ model Audiobook {
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
// Relations
|
||||
requests Request[]
|
||||
requests Request[]
|
||||
reportedIssues ReportedIssue[]
|
||||
|
||||
@@index([audibleAsin])
|
||||
@@index([plexGuid])
|
||||
@@ -456,3 +461,71 @@ model NotificationBackend {
|
||||
@@index([enabled])
|
||||
@@map("notification_backends")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REPORTED ISSUES TABLE
|
||||
// User-reported problems with available audiobooks (corrupted, wrong book, etc.)
|
||||
// ============================================================================
|
||||
|
||||
model ReportedIssue {
|
||||
id String @id @default(uuid())
|
||||
audiobookId String @map("audiobook_id")
|
||||
reporterId String @map("reporter_id")
|
||||
reason String @db.VarChar(250)
|
||||
status String @default("open") // open, dismissed, replaced
|
||||
resolvedAt DateTime? @map("resolved_at")
|
||||
resolvedById String? @map("resolved_by_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
|
||||
reporter User @relation("Reporter", fields: [reporterId], references: [id], onDelete: Cascade)
|
||||
resolvedBy User? @relation("Resolver", fields: [resolvedById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([audiobookId])
|
||||
@@index([reporterId])
|
||||
@@index([status])
|
||||
@@map("reported_issues")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GOODREADS SYNC TABLES
|
||||
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
|
||||
// ============================================================================
|
||||
|
||||
model GoodreadsShelf {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
name String // Extracted from RSS <title>
|
||||
rssUrl String @map("rss_url") @db.Text
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
bookCount Int? @map("book_count")
|
||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, rssUrl])
|
||||
@@index([userId])
|
||||
@@map("goodreads_shelves")
|
||||
}
|
||||
|
||||
model GoodreadsBookMapping {
|
||||
id String @id @default(uuid())
|
||||
goodreadsBookId String @unique @map("goodreads_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([goodreadsBookId])
|
||||
@@index([audibleAsin])
|
||||
@@map("goodreads_book_mappings")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Component: Admin Reported Issues Section
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*
|
||||
* Displays open reported issues on the admin dashboard.
|
||||
* Allows dismiss or search-for-replacement actions.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { fetchJSON } from '@/lib/utils/api';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
interface ReportedIssue {
|
||||
id: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
audiobook: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl: string | null;
|
||||
audibleAsin: string | null;
|
||||
};
|
||||
reporter: {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReportedIssuesSectionProps {
|
||||
issues: ReportedIssue[];
|
||||
}
|
||||
|
||||
export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
|
||||
const toast = useToast();
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [replaceIssue, setReplaceIssue] = useState<ReportedIssue | null>(null);
|
||||
|
||||
const handleDismiss = async (issueId: string) => {
|
||||
setLoadingStates((prev) => ({ ...prev, [issueId]: true }));
|
||||
|
||||
try {
|
||||
await fetchJSON(`/api/admin/reported-issues/${issueId}/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'dismiss' }),
|
||||
});
|
||||
|
||||
toast.success('Issue dismissed');
|
||||
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to dismiss issue: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
setLoadingStates((prev) => ({ ...prev, [issueId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceSuccess = async () => {
|
||||
toast.success('Replacement download started');
|
||||
setReplaceIssue(null);
|
||||
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/metrics'));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-6 h-6 text-orange-600 dark:text-orange-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Reported Issues
|
||||
</h2>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
{issues.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Issues Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{issues.map((issue) => {
|
||||
const isLoading = loadingStates[issue.id] || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="bg-white dark:bg-gray-800 border-2 border-orange-200 dark:border-orange-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
{/* Card Content */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{issue.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
{issue.audiobook.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{issue.audiobook.author}
|
||||
</p>
|
||||
|
||||
{/* Reporter */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{issue.reporter.avatarUrl ? (
|
||||
<img
|
||||
src={issue.reporter.avatarUrl}
|
||||
alt={issue.reporter.plexUsername}
|
||||
className="w-5 h-5 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-3 h-3 text-gray-600 dark:text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{issue.reporter.plexUsername}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{formatDistanceToNow(new Date(issue.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<p className="mt-3 text-sm text-gray-700 dark:text-gray-300 line-clamp-2 break-words bg-orange-50 dark:bg-orange-900/20 rounded-lg px-3 py-2 border border-orange-100 dark:border-orange-800/50">
|
||||
{issue.reason}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="border-t border-orange-200 dark:border-orange-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDismiss(issue.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Dismiss</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setReplaceIssue(issue)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Replace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Search Modal for Replacement */}
|
||||
{replaceIssue && createPortal(
|
||||
<div className="fixed inset-0 z-[60]">
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={!!replaceIssue}
|
||||
onClose={() => setReplaceIssue(null)}
|
||||
onSuccess={handleReplaceSuccess}
|
||||
audiobook={{
|
||||
title: replaceIssue.audiobook.title,
|
||||
author: replaceIssue.audiobook.author,
|
||||
}}
|
||||
asin={replaceIssue.audiobook.audibleAsin || undefined}
|
||||
replaceIssueId={replaceIssue.id}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+209
-123
@@ -78,13 +78,11 @@ function AdminJobsPageContent() {
|
||||
const showEditDialog = (job: ScheduledJob) => {
|
||||
setEditForm({ schedule: job.schedule, enabled: job.enabled });
|
||||
|
||||
// Check if it's a preset
|
||||
const preset = SCHEDULE_PRESETS.find(p => p.cron === job.schedule);
|
||||
if (preset) {
|
||||
setScheduleMode('preset');
|
||||
setSelectedPreset(preset.cron);
|
||||
} else {
|
||||
// Try to parse as custom schedule
|
||||
const parsed = cronToCustomSchedule(job.schedule);
|
||||
if (parsed.type === 'custom') {
|
||||
setScheduleMode('advanced');
|
||||
@@ -111,7 +109,7 @@ function AdminJobsPageContent() {
|
||||
method: 'POST',
|
||||
});
|
||||
toast.success(`Job "${jobName}" triggered successfully`);
|
||||
fetchJobs(); // Refresh list
|
||||
fetchJobs();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger job';
|
||||
toast.error(errorMsg);
|
||||
@@ -124,7 +122,6 @@ function AdminJobsPageContent() {
|
||||
const saveJobSchedule = async () => {
|
||||
if (!editDialog.job) return;
|
||||
|
||||
// Calculate final cron expression based on mode
|
||||
let finalCron: string;
|
||||
if (scheduleMode === 'preset') {
|
||||
finalCron = selectedPreset;
|
||||
@@ -134,7 +131,6 @@ function AdminJobsPageContent() {
|
||||
finalCron = editForm.schedule;
|
||||
}
|
||||
|
||||
// Validate cron expression
|
||||
if (!isValidCron(finalCron)) {
|
||||
toast.error('Invalid cron expression. Please check your schedule.');
|
||||
return;
|
||||
@@ -151,7 +147,7 @@ function AdminJobsPageContent() {
|
||||
});
|
||||
toast.success(`Job "${editDialog.job.name}" updated successfully`);
|
||||
hideEditDialog();
|
||||
fetchJobs(); // Refresh list
|
||||
fetchJobs();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update job';
|
||||
toast.error(errorMsg);
|
||||
@@ -173,36 +169,131 @@ function AdminJobsPageContent() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Scheduled Jobs
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Manage recurring tasks and automated jobs
|
||||
</p>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
|
||||
{/* Header — stacks on mobile, row on sm+ */}
|
||||
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Scheduled Jobs
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage recurring tasks and automated jobs
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
||||
<p className="text-red-800 dark:text-red-200 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jobs Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{/* Jobs — Card layout on mobile, Table on sm+ */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="px-4 py-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
|
||||
{job.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{job.type}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`flex-shrink-0 mt-0.5 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full ${
|
||||
job.enabled
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{job.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div className="px-4 pb-3 space-y-2 border-t border-gray-100 dark:border-gray-700/60 pt-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Schedule
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{cronToHuman(job.schedule)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
|
||||
{job.schedule}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Last Run
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card actions */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60 flex gap-2">
|
||||
<button
|
||||
onClick={() => showEditDialog(job)}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => showConfirmDialog(job.id, job.name)}
|
||||
disabled={triggering === job.id}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 text-blue-700 dark:text-blue-400 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{triggering === job.id ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Trigger
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{jobs.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No scheduled jobs found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Jobs Table — hidden on mobile, visible on sm+ */}
|
||||
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
@@ -312,31 +403,31 @@ function AdminJobsPageContent() {
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li>
|
||||
<li>• <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li>
|
||||
<li>• Trigger jobs manually using the "Trigger Now" button</li>
|
||||
<li>• Trigger jobs manually using the "Trigger Now" button</li>
|
||||
<li>• Schedule format follows cron syntax (minute hour day month weekday)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmDialog.isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl sm:rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Confirm Job Trigger
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6">
|
||||
Are you sure you want to trigger "{confirmDialog.jobName}" now?
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={hideConfirmDialog}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerJob}
|
||||
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
Trigger Job
|
||||
</button>
|
||||
@@ -347,12 +438,27 @@ function AdminJobsPageContent() {
|
||||
|
||||
{/* Edit Job Dialog */}
|
||||
{editDialog.isOpen && editDialog.job && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Edit Job Schedule
|
||||
</h3>
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
{/* Dialog header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Edit Job Schedule
|
||||
</h3>
|
||||
<button
|
||||
onClick={hideEditDialog}
|
||||
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Job Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
@@ -362,46 +468,29 @@ function AdminJobsPageContent() {
|
||||
type="text"
|
||||
value={editDialog.job.name}
|
||||
disabled
|
||||
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed"
|
||||
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Schedule Mode Tabs */}
|
||||
{/* Schedule Mode Tabs — grid on mobile to avoid overflow */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Schedule Type
|
||||
</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => setScheduleMode('preset')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
scheduleMode === 'preset'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Common Schedules
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScheduleMode('custom')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
scheduleMode === 'custom'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Custom Schedule
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScheduleMode('advanced')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
scheduleMode === 'advanced'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Advanced (Cron)
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-1 p-1 bg-gray-100 dark:bg-gray-700/60 rounded-xl mb-4">
|
||||
{(['preset', 'custom', 'advanced'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setScheduleMode(mode)}
|
||||
className={`px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
scheduleMode === mode
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{mode === 'preset' ? 'Common' : mode === 'custom' ? 'Custom' : 'Advanced'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preset Mode */}
|
||||
@@ -418,16 +507,16 @@ function AdminJobsPageContent() {
|
||||
value={preset.cron}
|
||||
checked={selectedPreset === preset.cron}
|
||||
onChange={(e) => setSelectedPreset(e.target.value)}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{preset.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{preset.description}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-1">
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
|
||||
{preset.cron}
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,8 +534,8 @@ function AdminJobsPageContent() {
|
||||
</label>
|
||||
<select
|
||||
value={customSchedule.type}
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as any })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as CustomSchedule['type'] })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="minutes">Every X minutes</option>
|
||||
<option value="hours">Every X hours</option>
|
||||
@@ -456,7 +545,6 @@ function AdminJobsPageContent() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Minutes/Hours Interval */}
|
||||
{(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
@@ -468,7 +556,7 @@ function AdminJobsPageContent() {
|
||||
max={customSchedule.type === 'minutes' ? 59 : 23}
|
||||
value={customSchedule.interval || 1}
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, interval: parseInt(e.target.value, 10) })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Run every {customSchedule.interval || 1} {customSchedule.type}
|
||||
@@ -476,12 +564,11 @@ function AdminJobsPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily/Weekly/Monthly Time */}
|
||||
{(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Hour (0-23)
|
||||
Hour (0–23)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -494,12 +581,12 @@ function AdminJobsPageContent() {
|
||||
time: { hour: parseInt(e.target.value, 10), minute: customSchedule.time?.minute || 0 },
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minute (0-59)
|
||||
Minute (0–59)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -512,13 +599,12 @@ function AdminJobsPageContent() {
|
||||
time: { hour: customSchedule.time?.hour || 0, minute: parseInt(e.target.value, 10) },
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly Day Selection */}
|
||||
{customSchedule.type === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
@@ -527,7 +613,7 @@ function AdminJobsPageContent() {
|
||||
<select
|
||||
value={customSchedule.dayOfWeek || 0}
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfWeek: parseInt(e.target.value, 10) })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="0">Sunday</option>
|
||||
<option value="1">Monday</option>
|
||||
@@ -540,11 +626,10 @@ function AdminJobsPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly Day Selection */}
|
||||
{customSchedule.type === 'monthly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Day of Month (1-31)
|
||||
Day of Month (1–31)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -552,12 +637,11 @@ function AdminJobsPageContent() {
|
||||
max="31"
|
||||
value={customSchedule.dayOfMonth || 1}
|
||||
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfMonth: parseInt(e.target.value, 10) })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
|
||||
Preview: {cronToHuman(customScheduleToCron(customSchedule))}
|
||||
@@ -571,30 +655,32 @@ function AdminJobsPageContent() {
|
||||
|
||||
{/* Advanced Mode */}
|
||||
{scheduleMode === 'advanced' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Cron Expression
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.schedule}
|
||||
onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })}
|
||||
placeholder="0 */6 * * *"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Format: minute hour day month weekday
|
||||
</p>
|
||||
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div>• */15 * * * * = Every 15 minutes</div>
|
||||
<div>• 0 */6 * * * = Every 6 hours</div>
|
||||
<div>• 0 0 * * * = Daily at midnight</div>
|
||||
<div>• 0 0 * * 0 = Weekly on Sunday</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Cron Expression
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.schedule}
|
||||
onChange={(e) => setEditForm({ ...editForm, schedule: e.target.value })}
|
||||
placeholder="0 */6 * * *"
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Format: minute hour day month weekday
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1 font-mono">
|
||||
<div>*/15 * * * * = Every 15 minutes</div>
|
||||
<div>0 */6 * * * = Every 6 hours</div>
|
||||
<div>0 0 * * * = Daily at midnight</div>
|
||||
<div>0 0 * * 0 = Weekly on Sunday</div>
|
||||
</div>
|
||||
</div>
|
||||
{editForm.schedule && (
|
||||
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
|
||||
Preview: {cronToHuman(editForm.schedule)}
|
||||
</div>
|
||||
@@ -604,34 +690,34 @@ function AdminJobsPageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enabled Checkbox */}
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{/* Enabled toggle */}
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={editForm.enabled}
|
||||
onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
Enable this job
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
{/* Dialog footer */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
|
||||
<button
|
||||
onClick={hideEditDialog}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1 sm:flex-none px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveJobSchedule}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1 sm:flex-none px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
|
||||
+236
-148
@@ -56,6 +56,119 @@ interface LogsData {
|
||||
};
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { dot: string; text: string; bg: string }> = {
|
||||
completed: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10' },
|
||||
failed: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-500/10' },
|
||||
active: { dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-500/10' },
|
||||
pending: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10' },
|
||||
delayed: { dot: 'bg-orange-500', text: 'text-orange-700 dark:text-orange-400', bg: 'bg-orange-500/10' },
|
||||
stuck: { dot: 'bg-purple-500', text: 'text-purple-700 dark:text-purple-400', bg: 'bg-purple-500/10' },
|
||||
};
|
||||
const c = config[status] ?? { dot: 'bg-gray-400', text: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-500/10' };
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogDetails({ log }: { log: Log }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{log.bullJobId && (
|
||||
<div className="flex flex-wrap gap-1.5 items-baseline">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Bull Job ID:</span>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">{log.bullJobId}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.events.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
Event Log
|
||||
</h4>
|
||||
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
|
||||
{log.events.map((event) => {
|
||||
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
|
||||
const levelColor = event.level === 'error'
|
||||
? 'text-red-400'
|
||||
: event.level === 'warn'
|
||||
? 'text-amber-400'
|
||||
: 'text-emerald-400';
|
||||
|
||||
return (
|
||||
<div key={event.id} className="text-gray-300 leading-relaxed">
|
||||
<span className={levelColor}>[{event.context}]</span>
|
||||
{' '}
|
||||
<span className="break-words">{event.message}</span>
|
||||
<span className="text-gray-500 ml-2">{timestamp}</span>
|
||||
{event.metadata && Object.keys(event.metadata).length > 0 && (
|
||||
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.result && Object.keys(log.result).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
Job Result
|
||||
</h4>
|
||||
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
|
||||
{JSON.stringify(log.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.errorMessage && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
Error
|
||||
</h4>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words">
|
||||
{log.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: string | null, completedAt: string | null) {
|
||||
if (!startedAt) return 'N/A';
|
||||
if (!completedAt) return 'Running…';
|
||||
const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function formatType(type: string) {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
|
||||
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
@@ -65,9 +178,7 @@ export default function AdminLogsPage() {
|
||||
const { data, error } = useSWR<LogsData>(
|
||||
`/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`,
|
||||
authenticatedFetcher,
|
||||
{
|
||||
refreshInterval: 10000, // Refresh every 10 seconds
|
||||
}
|
||||
{ refreshInterval: 10000 }
|
||||
);
|
||||
|
||||
const isLoading = !data && !error;
|
||||
@@ -87,9 +198,7 @@ export default function AdminLogsPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error Loading Logs
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Logs</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{error?.message || 'Failed to load system logs'}
|
||||
</p>
|
||||
@@ -101,80 +210,45 @@ export default function AdminLogsPage() {
|
||||
|
||||
const logs = data?.logs || [];
|
||||
const pagination = data?.pagination;
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'active':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
case 'delayed':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
|
||||
case 'stuck':
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (startedAt: string | null, completedAt: string | null) => {
|
||||
if (!startedAt) return 'N/A';
|
||||
if (!completedAt) return 'Running...';
|
||||
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = new Date(completedAt).getTime();
|
||||
const durationMs = end - start;
|
||||
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
System Logs
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
View background jobs and system activity
|
||||
</p>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
|
||||
{/* Header — stacks on mobile, row on sm+ */}
|
||||
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
System Logs
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
View background jobs and system activity
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap gap-4">
|
||||
{/* Filters — full-width stacked on mobile */}
|
||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
@@ -186,16 +260,13 @@ export default function AdminLogsPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Job Type
|
||||
</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => {
|
||||
setTypeFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="search_indexers">Search Indexers</option>
|
||||
@@ -215,8 +286,77 @@ export default function AdminLogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{/* Mobile card list — hidden on sm+ */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
|
||||
{formatType(log.type)}
|
||||
</div>
|
||||
<StatusBadge status={log.status} />
|
||||
</div>
|
||||
|
||||
{/* Related item */}
|
||||
{log.request?.audiobook ? (
|
||||
<div className="text-sm mb-2">
|
||||
<div className="text-gray-700 dark:text-gray-300 font-medium leading-snug">
|
||||
{log.request.audiobook.title}
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
by {log.request.audiobook.author} · {log.request.user.plexUsername}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">System job</div>
|
||||
)}
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{formatDateShort(log.createdAt)}</span>
|
||||
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
|
||||
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable details */}
|
||||
{hasDetails(log) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 border-t border-gray-100 dark:border-gray-700/60 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
|
||||
>
|
||||
<span>{expandedLog === log.id ? 'Hide Details' : 'Show Details'}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform duration-200 ${expandedLog === log.id ? 'rotate-180' : ''}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expandedLog === log.id && (
|
||||
<div className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60">
|
||||
<LogDetails log={log} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop table — hidden on mobile */}
|
||||
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
@@ -253,13 +393,11 @@ export default function AdminLogsPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{log.type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
{formatType(log.type)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeColor(log.status)}`}>
|
||||
{log.status.toUpperCase()}
|
||||
</span>
|
||||
<StatusBadge status={log.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{log.request?.audiobook ? (
|
||||
@@ -285,7 +423,7 @@ export default function AdminLogsPage() {
|
||||
{log.attempts}/{log.maxAttempts}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{(log.events.length > 0 || log.errorMessage || log.bullJobId || log.result) && (
|
||||
{hasDetails(log) && (
|
||||
<button
|
||||
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
@@ -298,63 +436,7 @@ export default function AdminLogsPage() {
|
||||
{expandedLog === log.id && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="space-y-4">
|
||||
{log.bullJobId && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Bull Job ID: </span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">{log.bullJobId}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Logs */}
|
||||
{log.events.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Event Log</h4>
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto bg-black/5 dark:bg-black/30 rounded p-3 font-mono text-xs">
|
||||
{log.events.map((event) => {
|
||||
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
|
||||
const levelColor = event.level === 'error'
|
||||
? 'text-red-500'
|
||||
: event.level === 'warn'
|
||||
? 'text-yellow-500'
|
||||
: 'text-green-500';
|
||||
|
||||
return (
|
||||
<div key={event.id} className="text-gray-800 dark:text-gray-200">
|
||||
<span className={levelColor}>[{event.context}]</span> {event.message}
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-2">{timestamp}</span>
|
||||
{event.metadata && Object.keys(event.metadata).length > 0 && (
|
||||
<pre className="ml-4 mt-1 text-gray-600 dark:text-gray-400 text-xs">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result Data */}
|
||||
{log.result && Object.keys(log.result).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Job Result</h4>
|
||||
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto">
|
||||
{JSON.stringify(log.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{log.errorMessage && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Error</h4>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap">
|
||||
{log.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LogDetails log={log} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -373,24 +455,31 @@ export default function AdminLogsPage() {
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total logs)
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 order-2 sm:order-1">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
<span className="hidden sm:inline"> ({pagination.total} total logs)</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 order-1 sm:order-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === pagination.totalPages}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,11 +492,10 @@ export default function AdminLogsPage() {
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• Logs are automatically refreshed every 10 seconds</li>
|
||||
<li>• Click "Show Details" to view detailed event logs, job results, and error messages</li>
|
||||
<li>• Event logs show all internal operations with timestamps (similar to Docker logs)</li>
|
||||
<li>• Tap "Show Details" to view event logs, job results, and errors</li>
|
||||
<li>• Event logs show all internal operations with timestamps</li>
|
||||
<li>• Jobs are retried automatically based on their max attempts setting</li>
|
||||
<li>• Use filters to find specific job types or statuses</li>
|
||||
<li>• All job types are tracked: searches, downloads, file organization, library scans, RSS monitoring, and more</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MetricCard } from './components/MetricCard';
|
||||
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
|
||||
import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -328,6 +329,14 @@ function AdminDashboardContent() {
|
||||
}
|
||||
);
|
||||
|
||||
const { data: reportedIssuesData } = useSWR(
|
||||
'/api/admin/reported-issues',
|
||||
authenticatedFetcher,
|
||||
{
|
||||
refreshInterval: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: settingsData } = useSWR(
|
||||
'/api/admin/settings',
|
||||
authenticatedFetcher,
|
||||
@@ -578,6 +587,11 @@ function AdminDashboardContent() {
|
||||
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
||||
)}
|
||||
|
||||
{/* Reported Issues */}
|
||||
{reportedIssuesData?.issues && reportedIssuesData.issues.length > 0 && (
|
||||
<ReportedIssuesSection issues={reportedIssuesData.issues} />
|
||||
)}
|
||||
|
||||
{/* Active Downloads */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
|
||||
@@ -295,6 +295,7 @@ export default function AdminSettings() {
|
||||
{activeTab === 'prowlarr' && (
|
||||
<IndexersTab
|
||||
settings={settings}
|
||||
originalSettings={originalSettings}
|
||||
indexers={configuredIndexers}
|
||||
flagConfigs={flagConfigs}
|
||||
onChange={setSettings}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
||||
@@ -16,6 +17,7 @@ import type { Settings, SavedIndexerConfig } from '../../lib/types';
|
||||
|
||||
interface IndexersTabProps {
|
||||
settings: Settings;
|
||||
originalSettings: Settings | null;
|
||||
indexers: SavedIndexerConfig[];
|
||||
flagConfigs: IndexerFlagConfig[];
|
||||
onChange: (settings: Settings) => void;
|
||||
@@ -27,6 +29,7 @@ interface IndexersTabProps {
|
||||
|
||||
export function IndexersTab({
|
||||
settings,
|
||||
originalSettings,
|
||||
indexers,
|
||||
flagConfigs,
|
||||
onChange,
|
||||
@@ -35,11 +38,23 @@ export function IndexersTab({
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
}: IndexersTabProps) {
|
||||
const { testing, testResult, testConnection } = useIndexersSettings({
|
||||
const {
|
||||
testing,
|
||||
testResult,
|
||||
testConnection,
|
||||
showConnectionChangeConfirm,
|
||||
confirmConnectionChange,
|
||||
cancelConnectionChange,
|
||||
configuredIndexersCount,
|
||||
} = useIndexersSettings({
|
||||
prowlarrUrl: settings.prowlarr.url,
|
||||
prowlarrApiKey: settings.prowlarr.apiKey,
|
||||
originalProwlarrUrl: originalSettings?.prowlarr.url ?? '',
|
||||
originalProwlarrApiKey: originalSettings?.prowlarr.apiKey ?? '',
|
||||
configuredIndexersCount: indexers.length,
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
onClearIndexers: () => onIndexersChange([]),
|
||||
});
|
||||
|
||||
// Auto-load indexers when component mounts if prowlarr is configured
|
||||
@@ -96,7 +111,7 @@ export function IndexersTab({
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Found in Prowlarr Settings → General → Security → API Key
|
||||
Found in Prowlarr Settings → General → Security → API Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -178,6 +193,19 @@ export function IndexersTab({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation modal for Prowlarr connection change */}
|
||||
<ConfirmModal
|
||||
isOpen={showConnectionChangeConfirm}
|
||||
onClose={cancelConnectionChange}
|
||||
onConfirm={confirmConnectionChange}
|
||||
title="Prowlarr Connection Change"
|
||||
message={`Changing your Prowlarr connection will remove your ${configuredIndexersCount} configured indexer${configuredIndexersCount === 1 ? '' : 's'}. Indexer IDs are specific to each Prowlarr instance, so existing configurations cannot be preserved. You will need to re-add indexers from the new instance after saving.`}
|
||||
confirmText="Continue"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
isLoading={testing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,30 +5,50 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { TestResult } from '../../lib/types';
|
||||
|
||||
interface UseIndexersSettingsProps {
|
||||
prowlarrUrl: string;
|
||||
prowlarrApiKey: string;
|
||||
originalProwlarrUrl: string;
|
||||
originalProwlarrApiKey: string;
|
||||
configuredIndexersCount: number;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
onRefreshIndexers?: () => Promise<void>;
|
||||
onClearIndexers: () => void;
|
||||
}
|
||||
|
||||
export function useIndexersSettings({
|
||||
prowlarrUrl,
|
||||
prowlarrApiKey,
|
||||
originalProwlarrUrl,
|
||||
originalProwlarrApiKey,
|
||||
configuredIndexersCount,
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
onClearIndexers,
|
||||
}: UseIndexersSettingsProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [showConnectionChangeConfirm, setShowConnectionChangeConfirm] = useState(false);
|
||||
|
||||
/**
|
||||
* Test Prowlarr connection
|
||||
* Detect if the Prowlarr URL or API key has changed from the saved values.
|
||||
* A masked API key (starting with dots) means the user hasn't touched it.
|
||||
*/
|
||||
const testConnection = async () => {
|
||||
const hasConnectionChanged = useCallback((): boolean => {
|
||||
const urlChanged = prowlarrUrl.trim() !== originalProwlarrUrl.trim();
|
||||
const apiKeyChanged = !prowlarrApiKey.startsWith('••••') &&
|
||||
prowlarrApiKey !== originalProwlarrApiKey;
|
||||
return urlChanged || apiKeyChanged;
|
||||
}, [prowlarrUrl, prowlarrApiKey, originalProwlarrUrl, originalProwlarrApiKey]);
|
||||
|
||||
/**
|
||||
* Execute the actual Prowlarr connection test
|
||||
*/
|
||||
const executeTest = async (shouldClearIndexers: boolean) => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
@@ -46,14 +66,23 @@ export function useIndexersSettings({
|
||||
|
||||
if (data.success) {
|
||||
onValidationChange(true);
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
|
||||
});
|
||||
|
||||
// Refresh indexers from database if callback provided
|
||||
if (onRefreshIndexers) {
|
||||
await onRefreshIndexers();
|
||||
if (shouldClearIndexers) {
|
||||
onClearIndexers();
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers. Previous indexer configurations have been removed — please re-add indexers from the new instance.`,
|
||||
});
|
||||
} else {
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
|
||||
});
|
||||
|
||||
// Refresh indexers from database if callback provided
|
||||
if (onRefreshIndexers) {
|
||||
await onRefreshIndexers();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onValidationChange(false);
|
||||
@@ -74,9 +103,41 @@ export function useIndexersSettings({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle test connection click — shows confirmation if credentials changed
|
||||
* and there are existing configured indexers.
|
||||
*/
|
||||
const testConnection = async () => {
|
||||
if (hasConnectionChanged() && configuredIndexersCount > 0) {
|
||||
setShowConnectionChangeConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeTest(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* User confirmed the credential change — proceed with test and clear indexers on success
|
||||
*/
|
||||
const confirmConnectionChange = async () => {
|
||||
setShowConnectionChangeConfirm(false);
|
||||
await executeTest(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* User cancelled the credential change confirmation
|
||||
*/
|
||||
const cancelConnectionChange = () => {
|
||||
setShowConnectionChangeConfirm(false);
|
||||
};
|
||||
|
||||
return {
|
||||
testing,
|
||||
testResult,
|
||||
testConnection,
|
||||
showConnectionChangeConfirm,
|
||||
confirmConnectionChange,
|
||||
cancelConnectionChange,
|
||||
configuredIndexersCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,11 +164,11 @@ export function AudiobookshelfSection({
|
||||
>
|
||||
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||
<option key={region.code} value={region.code}>
|
||||
{region.name}{!region.isEnglish ? ' *' : ''}
|
||||
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
|
||||
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
|
||||
@@ -164,11 +164,11 @@ export function PlexSection({
|
||||
>
|
||||
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||
<option key={region.code} value={region.code}>
|
||||
{region.name}{!region.isEnglish ? ' *' : ''}
|
||||
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && (
|
||||
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
|
||||
@@ -3,9 +3,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { EVENT_LABELS } from '@/lib/constants/notification-events';
|
||||
|
||||
const logger = RMABLogger.create('NotificationsTab');
|
||||
|
||||
interface ProviderConfigField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'select' | 'number';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
options?: { label: string; value: string | number }[];
|
||||
}
|
||||
|
||||
interface ProviderMetadata {
|
||||
type: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
iconLabel: string;
|
||||
iconColor: string;
|
||||
configFields: ProviderConfigField[];
|
||||
}
|
||||
|
||||
interface NotificationBackend {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -24,24 +44,11 @@ interface ModalState {
|
||||
backend?: NotificationBackend;
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
discord: 'bg-indigo-500',
|
||||
pushover: 'bg-blue-500',
|
||||
email: 'bg-green-500',
|
||||
slack: 'bg-purple-500',
|
||||
telegram: 'bg-sky-500',
|
||||
webhook: 'bg-gray-500',
|
||||
};
|
||||
|
||||
const eventLabels: Record<string, string> = {
|
||||
request_pending_approval: 'Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
const eventLabels: Record<string, string> = EVENT_LABELS;
|
||||
|
||||
export function NotificationsTab() {
|
||||
const [backends, setBackends] = useState<NotificationBackend[]>([]);
|
||||
const [providerMetadata, setProviderMetadata] = useState<ProviderMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
isOpen: false,
|
||||
@@ -59,8 +66,23 @@ export function NotificationsTab() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackends();
|
||||
fetchProviderMetadata();
|
||||
}, []);
|
||||
|
||||
const fetchProviderMetadata = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/notifications/providers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProviderMetadata(data.providers);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch provider metadata', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBackends = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -83,11 +105,23 @@ export function NotificationsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const getMetadataForType = (type: string): ProviderMetadata | undefined => {
|
||||
return providerMetadata.find((p) => p.type === type);
|
||||
};
|
||||
|
||||
const openAddModal = (type: string) => {
|
||||
const meta = getMetadataForType(type);
|
||||
const defaultConfig: Record<string, any> = {};
|
||||
if (meta) {
|
||||
for (const field of meta.configFields) {
|
||||
defaultConfig[field.name] = field.defaultValue ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
setModalState({ isOpen: true, mode: 'add', selectedType: type });
|
||||
setFormData({
|
||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Notifications`,
|
||||
config: type === 'discord' ? { webhookUrl: '', username: 'ReadMeABook', avatarUrl: '' } : { userKey: '', appToken: '', device: '', priority: 0 },
|
||||
name: `${meta?.displayName ?? type} Notifications`,
|
||||
config: defaultConfig,
|
||||
events: ['request_available', 'request_error'],
|
||||
enabled: true,
|
||||
});
|
||||
@@ -193,6 +227,49 @@ export function NotificationsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderConfigField = (field: ProviderConfigField) => {
|
||||
if (field.type === 'select' && field.options) {
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{field.label}{field.required ? ' *' : ''}
|
||||
</label>
|
||||
<select
|
||||
value={formData.config[field.name] ?? field.defaultValue ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = field.options?.some((o) => typeof o.value === 'number')
|
||||
? Number(e.target.value)
|
||||
: e.target.value;
|
||||
setFormData({ ...formData, config: { ...formData.config, [field.name]: value } });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
{field.options.map((opt) => (
|
||||
<option key={String(opt.value)} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{field.label}{field.required ? ' *' : field.label.includes('optional') ? '' : ' (optional)'}
|
||||
</label>
|
||||
<input
|
||||
type={field.type === 'password' ? 'password' : 'text'}
|
||||
value={formData.config[field.name] ?? ''}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, [field.name]: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const currentMeta = modalState.selectedType ? getMetadataForType(modalState.selectedType) : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -206,32 +283,22 @@ export function NotificationsTab() {
|
||||
{/* Type Selector */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => openAddModal('discord')}
|
||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
|
||||
D
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Discord</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Discord webhook</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAddModal('pushover')}
|
||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
|
||||
P
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Pushover</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Pushover API</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{providerMetadata.map((meta) => (
|
||||
<button
|
||||
key={meta.type}
|
||||
onClick={() => openAddModal(meta.type)}
|
||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className={`flex-shrink-0 w-12 h-12 ${meta.iconColor} rounded-lg flex items-center justify-center text-white font-bold text-2xl`}>
|
||||
{meta.iconLabel}
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{meta.displayName}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{meta.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -244,43 +311,46 @@ export function NotificationsTab() {
|
||||
<p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{backends.map((backend) => (
|
||||
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 ${typeColors[backend.type]} rounded-lg flex items-center justify-center text-white font-bold`}>
|
||||
{backend.type.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{backend.type}</div>
|
||||
{backends.map((backend) => {
|
||||
const meta = getMetadataForType(backend.type);
|
||||
return (
|
||||
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 ${meta?.iconColor ?? 'bg-gray-500'} rounded-lg flex items-center justify-center text-white font-bold`}>
|
||||
{meta?.iconLabel ?? backend.type.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{meta?.displayName ?? backend.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
|
||||
{backend.enabled ? 'Enabled' : 'Disabled'}
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
|
||||
{backend.enabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(backend)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(backend.id)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(backend)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(backend.id)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -292,7 +362,7 @@ export function NotificationsTab() {
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{modalState.mode === 'add' ? 'Add' : 'Edit'} {modalState.selectedType.charAt(0).toUpperCase() + modalState.selectedType.slice(1)} Notification
|
||||
{modalState.mode === 'add' ? 'Add' : 'Edit'} {currentMeta?.displayName ?? modalState.selectedType} Notification
|
||||
</h3>
|
||||
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -314,70 +384,8 @@ export function NotificationsTab() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Config Fields */}
|
||||
{modalState.selectedType === 'discord' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.webhookUrl}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, webhookUrl: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.username}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, username: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="ReadMeABook"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{modalState.selectedType === 'pushover' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Key *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.userKey}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, userKey: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="Your Pushover user key"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">App Token *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.appToken}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, appToken: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="Your Pushover app token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
|
||||
<select
|
||||
value={formData.config.priority}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, priority: Number(e.target.value) } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="-2">Lowest</option>
|
||||
<option value="-1">Low</option>
|
||||
<option value="0">Normal</option>
|
||||
<option value="1">High</option>
|
||||
<option value="2">Emergency</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Dynamic Config Fields */}
|
||||
{currentMeta?.configFields.map((field) => renderConfigField(field))}
|
||||
|
||||
{/* Events */}
|
||||
<div>
|
||||
|
||||
+366
-245
@@ -41,6 +41,144 @@ interface PendingUser {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Tinted-dot status badge following admin design system
|
||||
function RoleBadge({ role, isSetupAdmin }: { role: 'user' | 'admin'; isSetupAdmin: boolean }) {
|
||||
if (isSetupAdmin) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-700 dark:text-blue-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-blue-500" />
|
||||
Setup Admin
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (role === 'admin') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-700 dark:text-purple-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-purple-500" />
|
||||
Admin
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-gray-400" />
|
||||
User
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionBadge({
|
||||
user,
|
||||
globalAutoApprove,
|
||||
onClick,
|
||||
}: {
|
||||
user: User;
|
||||
globalAutoApprove: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
let badge: React.ReactNode;
|
||||
if (user.role === 'admin') {
|
||||
badge = (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-500/10 text-purple-700 dark:text-purple-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Full Access
|
||||
</span>
|
||||
);
|
||||
} else if (globalAutoApprove) {
|
||||
badge = (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-700 dark:text-blue-400">
|
||||
Global Default
|
||||
</span>
|
||||
);
|
||||
} else if (user.autoApproveRequests ?? false) {
|
||||
badge = (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
|
||||
Auto-Approve
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
badge = (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-500/10 text-gray-600 dark:text-gray-400">
|
||||
Manual
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 text-sm transition-opacity hover:opacity-70"
|
||||
>
|
||||
{badge}
|
||||
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UserActionsCell({ user, onEdit, onDelete }: { user: User; onEdit: (u: User) => void; onDelete: (u: User) => void }) {
|
||||
if (user.isSetupAdmin) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (user.authProvider === 'oidc') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>OIDC Managed</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (user.authProvider === 'local') {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onEdit(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(user)}
|
||||
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||
title="Delete user and all their requests"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// plex or other
|
||||
return (
|
||||
<button
|
||||
onClick={() => onEdit(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminUsersPageContent() {
|
||||
const { data, error, mutate } = useSWR('/api/admin/users', authenticatedFetcher);
|
||||
const { data: pendingData, error: pendingError, mutate: mutatePending } = useSWR(
|
||||
@@ -86,7 +224,6 @@ function AdminUsersPageContent() {
|
||||
if (globalAutoApproveData?.autoApproveRequests !== undefined) {
|
||||
setGlobalAutoApprove(globalAutoApproveData.autoApproveRequests);
|
||||
} else if (globalAutoApproveData !== undefined && globalAutoApproveData.autoApproveRequests === undefined) {
|
||||
// API returned but no value - default to true
|
||||
setGlobalAutoApprove(true);
|
||||
}
|
||||
}, [globalAutoApproveData]);
|
||||
@@ -101,9 +238,7 @@ function AdminUsersPageContent() {
|
||||
}, [globalInteractiveSearchData]);
|
||||
|
||||
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
|
||||
// Optimistic update
|
||||
setGlobalAutoApprove(newValue);
|
||||
|
||||
try {
|
||||
await fetchJSON('/api/admin/settings/auto-approve', {
|
||||
method: 'PATCH',
|
||||
@@ -111,20 +246,16 @@ function AdminUsersPageContent() {
|
||||
});
|
||||
toast.success(`Global auto-approve ${newValue ? 'enabled' : 'disabled'}`);
|
||||
mutateGlobalAutoApprove();
|
||||
mutate(); // Refresh users list to show updated state
|
||||
mutate();
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
setGlobalAutoApprove(!newValue);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update auto-approve setting';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => {
|
||||
// Optimistic update
|
||||
setGlobalInteractiveSearch(newValue);
|
||||
|
||||
try {
|
||||
await fetchJSON('/api/admin/settings/interactive-search', {
|
||||
method: 'PATCH',
|
||||
@@ -132,74 +263,51 @@ function AdminUsersPageContent() {
|
||||
});
|
||||
toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`);
|
||||
mutateGlobalInteractiveSearch();
|
||||
mutate(); // Refresh users list to show updated state
|
||||
mutate();
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
setGlobalInteractiveSearch(!newValue);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => {
|
||||
console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue });
|
||||
|
||||
// Optimistic update
|
||||
const previousUsers = data?.users || [];
|
||||
const optimisticUsers = previousUsers.map((u: User) =>
|
||||
u.id === user.id ? { ...u, autoApproveRequests: newValue } : u
|
||||
);
|
||||
console.log('[AutoApprove] Applying optimistic update');
|
||||
mutate({ users: optimisticUsers }, false);
|
||||
|
||||
try {
|
||||
console.log('[AutoApprove] Sending API request...');
|
||||
const response = await fetchJSON(`/api/admin/users/${user.id}`, {
|
||||
await fetchJSON(`/api/admin/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
role: user.role,
|
||||
autoApproveRequests: newValue
|
||||
}),
|
||||
body: JSON.stringify({ role: user.role, autoApproveRequests: newValue }),
|
||||
});
|
||||
console.log('[AutoApprove] API response received:', response);
|
||||
toast.success(`Auto-approve ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
|
||||
console.log('[AutoApprove] Triggering cache revalidation...');
|
||||
mutate(); // Refresh users list
|
||||
mutate();
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
console.error('[AutoApprove] Error occurred, reverting:', err);
|
||||
mutate({ users: previousUsers }, false);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update user auto-approve setting';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => {
|
||||
// Optimistic update
|
||||
const previousUsers = data?.users || [];
|
||||
const optimisticUsers = previousUsers.map((u: User) =>
|
||||
u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u
|
||||
);
|
||||
mutate({ users: optimisticUsers }, false);
|
||||
|
||||
try {
|
||||
await fetchJSON(`/api/admin/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
role: user.role,
|
||||
interactiveSearchAccess: newValue
|
||||
}),
|
||||
body: JSON.stringify({ role: user.role, interactiveSearchAccess: newValue }),
|
||||
});
|
||||
toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
|
||||
mutate(); // Refresh users list
|
||||
mutate();
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
mutate({ users: previousUsers }, false);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -214,7 +322,6 @@ function AdminUsersPageContent() {
|
||||
|
||||
const saveUserRole = async () => {
|
||||
if (!editDialog.user) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await fetchJSON(`/api/admin/users/${editDialog.user.id}`, {
|
||||
@@ -223,11 +330,10 @@ function AdminUsersPageContent() {
|
||||
});
|
||||
toast.success(`User "${editDialog.user.plexUsername}" updated successfully`);
|
||||
hideEditDialog();
|
||||
mutate(); // Refresh users list
|
||||
mutate();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update user';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -242,13 +348,12 @@ function AdminUsersPageContent() {
|
||||
};
|
||||
|
||||
const closeConfirmDialog = () => {
|
||||
if (processingUserId) return; // Don't close while processing
|
||||
if (processingUserId) return;
|
||||
setConfirmDialog({ isOpen: false, type: null, user: null });
|
||||
};
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
if (!confirmDialog.user) return;
|
||||
|
||||
const isApprove = confirmDialog.type === 'approve';
|
||||
try {
|
||||
setProcessingUserId(confirmDialog.user.id);
|
||||
@@ -261,13 +366,12 @@ function AdminUsersPageContent() {
|
||||
? `User "${confirmDialog.user.plexUsername}" has been approved`
|
||||
: `User "${confirmDialog.user.plexUsername}" has been rejected`
|
||||
);
|
||||
mutatePending(); // Refresh pending users list
|
||||
if (isApprove) mutate(); // Refresh approved users list
|
||||
mutatePending();
|
||||
if (isApprove) mutate();
|
||||
closeConfirmDialog();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : `Failed to ${isApprove ? 'approve' : 'reject'} user`;
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setProcessingUserId(null);
|
||||
}
|
||||
@@ -278,25 +382,23 @@ function AdminUsersPageContent() {
|
||||
};
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
if (deleting) return; // Don't close while processing
|
||||
if (deleting) return;
|
||||
setDeleteDialog({ isOpen: false, user: null });
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deleteDialog.user) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
const response = await fetchJSON(`/api/admin/users/${deleteDialog.user.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
toast.success(response.message || `User "${deleteDialog.user.plexUsername}" has been deleted`);
|
||||
mutate(); // Refresh users list
|
||||
mutate();
|
||||
closeDeleteDialog();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to delete user';
|
||||
toast.error(errorMsg);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
@@ -307,7 +409,6 @@ function AdminUsersPageContent() {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
@@ -327,9 +428,7 @@ function AdminUsersPageContent() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error Loading Users
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Users</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{error?.message || 'Failed to load users'}
|
||||
</p>
|
||||
@@ -344,80 +443,81 @@ function AdminUsersPageContent() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Manage user roles and permissions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setGlobalSettingsOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>Global User Permissions</span>
|
||||
</button>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
|
||||
{/* Header — stacks on mobile, row on sm+ */}
|
||||
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage user roles and permissions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setGlobalSettingsOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Global User Permissions</span>
|
||||
<span className="sm:hidden">Permissions</span>
|
||||
</button>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Users Section */}
|
||||
{pendingUsers.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
|
||||
<h2 className="text-lg font-semibold text-yellow-900 dark:text-yellow-200 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800/60 rounded-xl p-4">
|
||||
<h2 className="text-base font-semibold text-amber-900 dark:text-amber-200 mb-1 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Pending Registrations ({pendingUsers.length})
|
||||
</h2>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-4">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300/80 mb-4">
|
||||
The following users are awaiting approval to access the system.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{pendingUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="bg-white dark:bg-gray-800 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 flex items-center justify-between"
|
||||
className="bg-white dark:bg-gray-800 border border-amber-200 dark:border-amber-800/40 rounded-xl overflow-hidden"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{user.plexUsername}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.plexEmail || 'No email'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Registered: {new Date(user.createdAt).toLocaleString()} •
|
||||
Provider: {user.authProvider}
|
||||
</div>
|
||||
</div>
|
||||
{/* Pending card — info */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
|
||||
{user.plexUsername}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.plexEmail || 'No email'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Registered: {new Date(user.createdAt).toLocaleString()} · Provider: {user.authProvider}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Pending card — actions, full-width on mobile */}
|
||||
<div className="px-4 py-3 border-t border-amber-100 dark:border-amber-800/30 flex gap-2">
|
||||
<button
|
||||
onClick={() => showApproveDialog(user)}
|
||||
disabled={processingUserId === user.id}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200/60 dark:border-emerald-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
@@ -427,7 +527,7 @@ function AdminUsersPageContent() {
|
||||
<button
|
||||
onClick={() => showRejectDialog(user)}
|
||||
disabled={processingUserId === user.id}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-red-50 dark:bg-red-500/10 hover:bg-red-100 dark:hover:bg-red-500/20 text-red-700 dark:text-red-400 border border-red-200/60 dark:border-red-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -442,8 +542,104 @@ function AdminUsersPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
|
||||
{/* Users — Mobile card list (sm:hidden) */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Card header — avatar + name + role badge */}
|
||||
<div className="px-4 py-3 flex items-start gap-3">
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.plexUsername}
|
||||
className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug truncate">
|
||||
{user.plexUsername}
|
||||
</div>
|
||||
<RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{user.plexEmail || 'No email'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card body — labeled fields */}
|
||||
<div className="px-4 pb-3 pt-2 space-y-2 border-t border-gray-100 dark:border-gray-700/60">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Permissions
|
||||
</div>
|
||||
<PermissionBadge
|
||||
user={user}
|
||||
globalAutoApprove={globalAutoApprove}
|
||||
onClick={() => setPermissionsUserId(user.id)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Requests
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{user._count.requests}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Last Login
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{user.lastLoginAt
|
||||
? new Date(user.lastLoginAt).toLocaleDateString()
|
||||
: 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
User ID
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard(user.plexId, 'User ID')}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{user.plexId.length > 16 ? `${user.plexId.substring(0, 16)}…` : user.plexId}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card actions */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60">
|
||||
<UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No users found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Users Table — hidden on mobile, visible on sm+ */}
|
||||
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
@@ -472,15 +668,21 @@ function AdminUsersPageContent() {
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{user.avatarUrl && (
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.plexUsername}
|
||||
className="h-10 w-10 rounded-full mr-3"
|
||||
className="h-10 w-10 rounded-full mr-3 flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full mr-3 flex-shrink-0 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
@@ -507,52 +709,14 @@ function AdminUsersPageContent() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{user.role.toUpperCase()}
|
||||
</span>
|
||||
{user.isSetupAdmin && (
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
SETUP ADMIN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
<PermissionBadge
|
||||
user={user}
|
||||
globalAutoApprove={globalAutoApprove}
|
||||
onClick={() => setPermissionsUserId(user.id)}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{user.role === 'admin' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Full Access
|
||||
</span>
|
||||
) : globalAutoApprove ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
Global Default
|
||||
</span>
|
||||
) : (user.autoApproveRequests ?? false) ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
Auto-Approve
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{user._count.requests}
|
||||
@@ -563,65 +727,7 @@ function AdminUsersPageContent() {
|
||||
: 'Never'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{user.isSetupAdmin ? (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : user.authProvider === 'oidc' ? (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider (use admin role mapping in settings)">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>OIDC Managed</span>
|
||||
</span>
|
||||
) : user.authProvider === 'plex' ? (
|
||||
<button
|
||||
onClick={() => showEditDialog(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
) : user.authProvider === 'local' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => showEditDialog(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => showDeleteDialog(user)}
|
||||
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete user and all their requests"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => showEditDialog(user)}
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -643,31 +749,50 @@ function AdminUsersPageContent() {
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
|
||||
<li>• <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
|
||||
<li>• <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
|
||||
<li>• <strong>Permissions:</strong> Click a user's permission badge to manage individual settings (auto-approve, interactive search). Use Global User Permissions to control system-wide defaults. Admins always have full access.</li>
|
||||
<li>• <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
|
||||
<li>• <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
|
||||
<li>• <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
|
||||
<li>• <strong>Setup Admin:</strong> The initial admin account — protected, cannot be changed or deleted</li>
|
||||
<li>• <strong>Permissions:</strong> Click a user's permission badge to manage individual settings. Use Global User Permissions for system-wide defaults. Admins always have full access.</li>
|
||||
<li>• <strong>OIDC Users:</strong> Role management is handled by the identity provider. Cannot be deleted.</li>
|
||||
<li>• <strong>Plex Users:</strong> Role can be changed, but cannot be deleted (access managed by Plex).</li>
|
||||
<li>• <strong>Local Users:</strong> Can have roles freely assigned. Can be deleted (requests are preserved).</li>
|
||||
<li>• You cannot change your own role or delete yourself for security reasons</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
{/* Edit User Dialog — bottom sheet on mobile */}
|
||||
{editDialog.isOpen && editDialog.user && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Edit User Role
|
||||
</h3>
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-md">
|
||||
{/* Dialog header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Edit User Role
|
||||
</h3>
|
||||
<button
|
||||
onClick={hideEditDialog}
|
||||
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
{editDialog.user.avatarUrl && (
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
|
||||
{editDialog.user.avatarUrl ? (
|
||||
<img
|
||||
src={editDialog.user.avatarUrl}
|
||||
alt={editDialog.user.plexUsername}
|
||||
className="h-12 w-12 rounded-full"
|
||||
className="h-12 w-12 rounded-full flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-full flex-shrink-0 bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
@@ -685,38 +810,34 @@ function AdminUsersPageContent() {
|
||||
Role
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
||||
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value="user"
|
||||
checked={editRole === 'user'}
|
||||
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
User
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">User</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Can request audiobooks and view own requests
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
||||
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value="admin"
|
||||
checked={editRole === 'admin'}
|
||||
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Admin
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Admin</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Full system access including settings and user management
|
||||
</div>
|
||||
</div>
|
||||
@@ -725,19 +846,19 @@ function AdminUsersPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
{/* Dialog footer */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
|
||||
<button
|
||||
onClick={hideEditDialog}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveUserRole}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
||||
import { getNotificationService } from '@/lib/services/notification';
|
||||
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -15,7 +16,7 @@ const logger = RMABLogger.create('API.Admin.Notifications.Id');
|
||||
const UpdateBackendSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
config: z.record(z.any()).optional(),
|
||||
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1).optional(),
|
||||
events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -50,7 +51,7 @@ export async function GET(
|
||||
success: true,
|
||||
backend: {
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
config: notificationService.maskConfig(backend.type, backend.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -114,7 +115,7 @@ export async function PUT(
|
||||
});
|
||||
|
||||
// Encrypt new/changed values
|
||||
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig);
|
||||
finalConfig = notificationService.encryptConfig(existing.type, updatedConfig);
|
||||
}
|
||||
|
||||
// Update backend
|
||||
@@ -139,7 +140,7 @@ export async function PUT(
|
||||
success: true,
|
||||
backend: {
|
||||
...updated,
|
||||
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config),
|
||||
config: notificationService.maskConfig(updated.type, updated.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Component: Notification Providers Metadata API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getAllProviderMetadata } from '@/lib/services/notification';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Providers');
|
||||
|
||||
/**
|
||||
* GET /api/admin/notifications/providers
|
||||
* Returns metadata for all registered notification providers
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const providers = getAllProviderMetadata();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
providers,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch provider metadata', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'FetchError',
|
||||
message: 'Failed to fetch provider metadata',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,17 +6,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
||||
import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification';
|
||||
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications');
|
||||
|
||||
const CreateBackendSchema = z.object({
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }),
|
||||
name: z.string().min(1),
|
||||
config: z.record(z.any()),
|
||||
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
|
||||
events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
@@ -37,7 +38,7 @@ export async function GET(request: NextRequest) {
|
||||
// Mask sensitive config values
|
||||
const maskedBackends = backends.map((backend) => ({
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
config: notificationService.maskConfig(backend.type, backend.config),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -5,31 +5,17 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service';
|
||||
import { getNotificationService, getRegisteredProviderTypes, NotificationPayload } from '@/lib/services/notification';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Test');
|
||||
|
||||
const TestNotificationSchema = z.discriminatedUnion('mode', [
|
||||
// Test existing backend by ID (uses stored config)
|
||||
z.object({
|
||||
mode: z.literal('backend'),
|
||||
backendId: z.string(),
|
||||
}),
|
||||
// Test new config before saving
|
||||
z.object({
|
||||
mode: z.literal('config'),
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
config: z.record(z.any()),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Support legacy format without mode
|
||||
const LegacyTestNotificationSchema = z.object({
|
||||
// Flexible schema: supports both backendId and type+config formats
|
||||
const TestNotificationSchema = z.object({
|
||||
backendId: z.string().optional(),
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']).optional(),
|
||||
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }).optional(),
|
||||
config: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
@@ -42,66 +28,37 @@ export async function POST(request: NextRequest) {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = TestNotificationSchema.parse(body);
|
||||
|
||||
// Support legacy format for backward compatibility
|
||||
const legacyParsed = LegacyTestNotificationSchema.safeParse(body);
|
||||
|
||||
let type: NotificationBackendType;
|
||||
let type: string;
|
||||
let encryptedConfig: any;
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
if (legacyParsed.success) {
|
||||
// Legacy format
|
||||
if (legacyParsed.data.backendId) {
|
||||
// Test existing backend
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: legacyParsed.data.backendId },
|
||||
});
|
||||
if (parsed.backendId) {
|
||||
// Test existing backend by ID (uses stored config)
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: parsed.backendId },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type as NotificationBackendType;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else if (legacyParsed.data.type && legacyParsed.data.config) {
|
||||
// Test new config
|
||||
type = legacyParsed.data.type as NotificationBackendType;
|
||||
encryptedConfig = notificationService.encryptConfig(type, legacyParsed.data.config);
|
||||
} else {
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
|
||||
{ status: 400 }
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else if (parsed.type && parsed.config) {
|
||||
// Test new config before saving
|
||||
type = parsed.type;
|
||||
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
|
||||
} else {
|
||||
// New format with discriminated union
|
||||
const parsed = TestNotificationSchema.parse(body);
|
||||
|
||||
if (parsed.mode === 'backend') {
|
||||
// Test existing backend
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: parsed.backendId },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type as NotificationBackendType;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else {
|
||||
// Test new config
|
||||
type = parsed.type;
|
||||
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create test payload
|
||||
@@ -111,13 +68,14 @@ export async function POST(request: NextRequest) {
|
||||
title: "The Hitchhiker's Guide to the Galaxy",
|
||||
author: 'Douglas Adams',
|
||||
userName: 'Test User',
|
||||
requestType: 'audiobook',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Send test notification synchronously (not via job queue)
|
||||
try {
|
||||
// Call sendToBackend directly
|
||||
await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload);
|
||||
await notificationService.sendToBackend(type, encryptedConfig, testPayload);
|
||||
|
||||
logger.info(`Test notification sent successfully for ${type}`, {
|
||||
adminId: req.user?.sub,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Component: Admin Replace Audiobook API
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { replaceAudiobook, ReportedIssueError } from '@/lib/services/reported-issue.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ReportedIssues.Replace');
|
||||
|
||||
const ReplaceSchema = z.object({
|
||||
torrent: z.object({
|
||||
guid: z.string(),
|
||||
title: z.string(),
|
||||
size: z.number(),
|
||||
seeders: z.number().optional(),
|
||||
leechers: z.number().optional(),
|
||||
indexer: z.string(),
|
||||
indexerId: z.number().optional(),
|
||||
downloadUrl: z.string(),
|
||||
infoUrl: z.string().optional(),
|
||||
publishDate: z.string().transform((str) => new Date(str)),
|
||||
infoHash: z.string().optional(),
|
||||
format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
|
||||
bitrate: z.string().optional(),
|
||||
hasChapters: z.boolean().optional(),
|
||||
protocol: z.enum(['torrent', 'usenet']).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/reported-issues/[id]/replace
|
||||
* Atomically replace audiobook content: delete old → create new request → start download → resolve issue
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { torrent } = ReplaceSchema.parse(body);
|
||||
|
||||
const result = await replaceAudiobook(id, req.user.id, torrent);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: result.request,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof ReportedIssueError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ReplaceError', message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Failed to replace audiobook', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to replace audiobook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Component: Admin Resolve Reported Issue API
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { dismissIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ReportedIssues.Resolve');
|
||||
|
||||
const ResolveSchema = z.object({
|
||||
action: z.enum(['dismiss']),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/reported-issues/[id]/resolve
|
||||
* Dismiss a reported issue
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { action } = ResolveSchema.parse(body);
|
||||
|
||||
if (action === 'dismiss') {
|
||||
const issue = await dismissIssue(id, req.user.id);
|
||||
return NextResponse.json({ success: true, issue });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'InvalidAction', message: 'Unknown action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof ReportedIssueError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ResolveError', message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Failed to resolve issue', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to resolve issue' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Component: Admin Reported Issues List API
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getOpenIssues } from '@/lib/services/reported-issue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ReportedIssues');
|
||||
|
||||
/**
|
||||
* GET /api/admin/reported-issues
|
||||
* Get all open reported issues with audiobook metadata and reporter info
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const issues = await getOpenIssues();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
issues,
|
||||
count: issues.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch reported issues', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to fetch reported issues' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export async function PUT(
|
||||
localPath,
|
||||
category,
|
||||
customPath,
|
||||
postImportCategory,
|
||||
} = body;
|
||||
|
||||
const config = await getConfigService();
|
||||
@@ -76,6 +77,7 @@ export async function PUT(
|
||||
localPath: localPath !== undefined ? localPath : existingClient.localPath,
|
||||
category: category !== undefined ? category : existingClient.category,
|
||||
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
|
||||
postImportCategory: postImportCategory !== undefined ? (postImportCategory || undefined) : existingClient.postImportCategory,
|
||||
};
|
||||
|
||||
// Validate path mapping if enabled
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Component: Fetch Download Client Categories API
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Categories');
|
||||
|
||||
/**
|
||||
* POST - Fetch categories from a download client
|
||||
* Accepts same connection config as the test endpoint
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
clientId,
|
||||
type,
|
||||
name: clientName,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
disableSSLVerify,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
} = body;
|
||||
|
||||
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: 'URL is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfigService();
|
||||
const manager = getDownloadClientManager(config);
|
||||
|
||||
// If editing and password not provided, use stored password
|
||||
let effectivePassword = password;
|
||||
let effectiveUsername = username;
|
||||
|
||||
if (clientId && !password) {
|
||||
const existingClients = await manager.getAllClients();
|
||||
const existingClient = existingClients.find(c => c.id === clientId);
|
||||
|
||||
if (!existingClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
effectivePassword = existingClient.password;
|
||||
if (!username && existingClient.username) {
|
||||
effectiveUsername = existingClient.username;
|
||||
}
|
||||
}
|
||||
|
||||
const testConfig: DownloadClientConfig = {
|
||||
id: 'categories-fetch',
|
||||
type,
|
||||
name: clientName || type,
|
||||
enabled: true,
|
||||
url,
|
||||
username: effectiveUsername || '',
|
||||
password: effectivePassword || '',
|
||||
disableSSLVerify: disableSSLVerify || false,
|
||||
remotePathMappingEnabled: remotePathMappingEnabled || false,
|
||||
remotePath: remotePath || undefined,
|
||||
localPath: localPath || undefined,
|
||||
category: 'readmeabook',
|
||||
};
|
||||
|
||||
const service = await manager.createClientFromConfig(testConfig);
|
||||
const categories = await service.getCategories();
|
||||
|
||||
return NextResponse.json({ success: true, categories });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Failed to fetch categories', { error: message });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export async function POST(request: NextRequest) {
|
||||
localPath,
|
||||
category,
|
||||
customPath,
|
||||
postImportCategory,
|
||||
} = body;
|
||||
|
||||
// Validate type
|
||||
@@ -138,6 +139,7 @@ export async function POST(request: NextRequest) {
|
||||
localPath: localPath || undefined,
|
||||
category: category || 'readmeabook',
|
||||
customPath: customPath || undefined,
|
||||
postImportCategory: postImportCategory || undefined,
|
||||
};
|
||||
|
||||
// Test connection before saving
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { invalidateProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
|
||||
@@ -42,6 +43,9 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate cached singleton so background jobs use new credentials
|
||||
invalidateProwlarrService();
|
||||
|
||||
logger.info('Prowlarr settings updated');
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -18,6 +18,8 @@ import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
|
||||
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||
import type { AudibleRegion } from '@/lib/types/audible';
|
||||
import {
|
||||
searchByAsin,
|
||||
searchByTitle,
|
||||
@@ -227,6 +229,11 @@ export async function POST(
|
||||
const format = preferredFormat || 'epub';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||
|
||||
// Get language code from Audible region config
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
const languageCode = langConfig.annasArchiveLang;
|
||||
|
||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
||||
@@ -250,7 +257,8 @@ export async function POST(
|
||||
audiobook.author,
|
||||
format,
|
||||
annasBaseUrl,
|
||||
flaresolverrUrl || undefined
|
||||
flaresolverrUrl || undefined,
|
||||
languageCode
|
||||
).catch((err) => {
|
||||
logger.error(`Anna's Archive search failed: ${err.message}`);
|
||||
return null;
|
||||
@@ -322,7 +330,8 @@ async function searchAnnasArchiveForInteractive(
|
||||
author: string,
|
||||
preferredFormat: string,
|
||||
baseUrl: string,
|
||||
flaresolverrUrl?: string
|
||||
flaresolverrUrl?: string,
|
||||
languageCode: string = 'en'
|
||||
): Promise<EbookSearchResult[]> {
|
||||
let md5: string | null = null;
|
||||
let searchMethod: 'asin' | 'title' = 'title';
|
||||
@@ -330,7 +339,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Try ASIN search first
|
||||
if (asin) {
|
||||
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||
if (md5) {
|
||||
searchMethod = 'asin';
|
||||
logger.info(`Found via ASIN: ${md5}`);
|
||||
@@ -340,7 +349,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Fallback to title search
|
||||
if (!md5) {
|
||||
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||
if (md5) {
|
||||
logger.info(`Found via title: ${md5}`);
|
||||
}
|
||||
@@ -461,6 +470,10 @@ async function searchIndexersForInteractive(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get language-specific stop words for ranking
|
||||
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const rankLangConfig = getLanguageForRegion(rankRegion);
|
||||
|
||||
// Rank results with ebook scoring
|
||||
const rankedResults = rankEbookTorrents(allResults, {
|
||||
title,
|
||||
@@ -470,6 +483,8 @@ async function searchIndexersForInteractive(
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false,
|
||||
stopWords: rankLangConfig.stopWords,
|
||||
characterReplacements: rankLangConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// Convert to unified result type
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Component: Report Issue API
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { reportIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.ReportIssue');
|
||||
|
||||
const ReportIssueSchema = z.object({
|
||||
reason: z.string().min(1, 'Reason is required').max(250, 'Reason must be 250 characters or less'),
|
||||
title: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
coverArtUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/audiobooks/[asin]/report-issue
|
||||
* Report an issue with an available audiobook
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { asin } = await params;
|
||||
const body = await req.json();
|
||||
const { reason, title, author, coverArtUrl } = ReportIssueSchema.parse(body);
|
||||
|
||||
const issue = await reportIssue(asin, req.user.id, reason, { title, author, coverArtUrl });
|
||||
|
||||
return NextResponse.json({ success: true, issue }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof ReportedIssueError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ReportIssueError', message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Failed to report issue', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to report issue' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export async function GET(
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobook,
|
||||
audibleBaseUrl: audibleService.getBaseUrl(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get audiobook details', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -10,6 +10,8 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||
import type { AudibleRegion } from '@/lib/types/audible';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -86,7 +88,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Search Prowlarr for each group and combine results
|
||||
const prowlarr = await getProwlarrService();
|
||||
const searchQuery = title; // Title only - cast wide net
|
||||
const allResults = [];
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
@@ -94,7 +95,7 @@ export async function POST(request: NextRequest) {
|
||||
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||
|
||||
try {
|
||||
const groupResults = await prowlarr.search(searchQuery, {
|
||||
const groupResults = await prowlarr.searchWithVariations(title, author, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
maxResults: 100, // Limit per group
|
||||
@@ -141,13 +142,19 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
|
||||
}
|
||||
|
||||
// Get language-specific stop words for ranking
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
|
||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||
// Note: rankTorrents now filters out results < 20 MB internally
|
||||
// requireAuthor: false - interactive search, show all results for user decision
|
||||
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false // Interactive mode - let user decide
|
||||
requireAuthor: false, // Interactive mode - let user decide
|
||||
stopWords: langConfig.stopWords,
|
||||
characterReplacements: langConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// Log filter results
|
||||
|
||||
@@ -39,7 +39,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Validate new password length
|
||||
if (newPassword.length < 8) {
|
||||
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||
if (!allowWeakPassword && newPassword.length < 8) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function GET() {
|
||||
// Check if local login is disabled via environment variable
|
||||
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
||||
|
||||
// Check if weak passwords are allowed via environment variable
|
||||
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||
|
||||
// Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config
|
||||
const indexerType = await configService.get('indexer.type');
|
||||
const prowlarrUrl = await configService.get('indexer.prowlarr_url');
|
||||
@@ -47,6 +50,7 @@ export async function GET() {
|
||||
hasLocalUsers,
|
||||
oidcProviderName: oidcEnabled ? oidcProviderName : null,
|
||||
localLoginDisabled,
|
||||
allowWeakPassword,
|
||||
automationEnabled,
|
||||
});
|
||||
} else {
|
||||
@@ -65,6 +69,7 @@ export async function GET() {
|
||||
hasLocalUsers,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled,
|
||||
allowWeakPassword,
|
||||
automationEnabled,
|
||||
});
|
||||
}
|
||||
@@ -72,6 +77,7 @@ export async function GET() {
|
||||
logger.error('Failed to fetch auth providers', { error: error instanceof Error ? error.message : String(error) });
|
||||
// Default to Plex mode if config can't be read
|
||||
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
||||
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||
return NextResponse.json({
|
||||
backendMode: 'plex',
|
||||
providers: ['plex'],
|
||||
@@ -79,6 +85,7 @@ export async function GET() {
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled,
|
||||
allowWeakPassword,
|
||||
automationEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Component: Author Books API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Authors.Books');
|
||||
|
||||
/**
|
||||
* GET /api/authors/{asin}/books?name=Author+Name
|
||||
* Scrape Audible for all books by this author, filtered by ASIN and English language.
|
||||
* Enriched with library availability and request status.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
try {
|
||||
const currentUser = getCurrentUser(request);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { asin } = await params;
|
||||
const authorName = request.nextUrl.searchParams.get('name');
|
||||
|
||||
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Valid author ASIN is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!authorName || authorName.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Author name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`);
|
||||
|
||||
const audibleService = getAudibleService();
|
||||
const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin);
|
||||
|
||||
// Enrich with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(books, userId);
|
||||
|
||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
books: enrichedBooks,
|
||||
authorName: authorName.trim(),
|
||||
authorAsin: asin,
|
||||
totalBooks: enrichedBooks.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch author books' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Component: Author Detail API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import {
|
||||
AudnexusAuthorDetail,
|
||||
fetchAuthorDetail,
|
||||
} from '@/lib/integrations/audnexus-authors';
|
||||
|
||||
const logger = RMABLogger.create('API.Authors.Detail');
|
||||
|
||||
const SIMILAR_AUTHORS_LIMIT = 15;
|
||||
|
||||
/**
|
||||
* GET /api/authors/{asin}
|
||||
* Fetch author detail from Audnexus with enriched similar author images
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
try {
|
||||
const currentUser = getCurrentUser(request);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { asin } = await params;
|
||||
|
||||
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Valid author ASIN is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const configService = getConfigService();
|
||||
const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
|
||||
const regionConfig = AUDIBLE_REGIONS[audibleRegion] || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION];
|
||||
const region = regionConfig.audnexusParam;
|
||||
|
||||
logger.info(`Fetching author detail: ${asin} (region: ${region})`);
|
||||
|
||||
// Fetch the primary author detail
|
||||
const detail = await fetchAuthorDetail(asin, region);
|
||||
if (!detail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Author not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch images for similar authors in parallel (capped)
|
||||
const similarSlice = (detail.similar || []).slice(0, SIMILAR_AUTHORS_LIMIT);
|
||||
const similarDetails = await Promise.all(
|
||||
similarSlice.map(s => fetchAuthorDetail(s.asin, region))
|
||||
);
|
||||
|
||||
const similarAuthors = similarSlice.map((s, i) => ({
|
||||
asin: s.asin,
|
||||
name: s.name,
|
||||
image: similarDetails[i]?.image || undefined,
|
||||
}));
|
||||
|
||||
const author = {
|
||||
asin: detail.asin,
|
||||
name: detail.name,
|
||||
description: detail.description || undefined,
|
||||
image: detail.image || undefined,
|
||||
genres: detail.genres?.map(g => g.name) || [],
|
||||
similar: similarAuthors,
|
||||
audibleUrl: `${regionConfig.baseUrl}/author/${asin}`,
|
||||
};
|
||||
|
||||
logger.info(`Author detail complete: "${detail.name}" (${similarAuthors.length} similar authors)`);
|
||||
|
||||
return NextResponse.json({ success: true, author });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch author detail', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch author details' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Component: Author Search API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION, AudibleRegion } from '@/lib/types/audible';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import {
|
||||
AudnexusAuthorDetail,
|
||||
searchAuthors,
|
||||
fetchAuthorDetail,
|
||||
} from '@/lib/integrations/audnexus-authors';
|
||||
|
||||
const logger = RMABLogger.create('API.Authors.Search');
|
||||
|
||||
/**
|
||||
* GET /api/authors/search?name=Brandon Sanderson
|
||||
* Search for authors on Audnexus, deduplicate, and return enriched details
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Require authentication
|
||||
const currentUser = getCurrentUser(request);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const name = request.nextUrl.searchParams.get('name');
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Author name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get configured Audible region
|
||||
const configService = getConfigService();
|
||||
const audibleRegion: AudibleRegion = await configService.getAudibleRegion();
|
||||
const region = AUDIBLE_REGIONS[audibleRegion]?.audnexusParam || AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].audnexusParam;
|
||||
|
||||
logger.info(`Searching authors: "${name}" (region: ${region})`);
|
||||
|
||||
// Step 1: Search for authors (returns list with potential duplicates)
|
||||
const searchResults = await searchAuthors(name.trim(), region);
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
authors: [],
|
||||
query: name.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Fetch details for all unique authors in parallel
|
||||
const detailPromises = searchResults.map(author => fetchAuthorDetail(author.asin, region));
|
||||
const detailResults = await Promise.all(detailPromises);
|
||||
|
||||
// Step 3: Build enriched results, filtering out any failed fetches
|
||||
const authors = detailResults
|
||||
.filter((detail): detail is AudnexusAuthorDetail => detail !== null)
|
||||
.map(detail => ({
|
||||
asin: detail.asin,
|
||||
name: detail.name,
|
||||
description: detail.description || undefined,
|
||||
image: detail.image || undefined,
|
||||
genres: detail.genres?.map(g => g.name).slice(0, 3) || [],
|
||||
similarCount: detail.similar?.length || 0,
|
||||
}));
|
||||
|
||||
logger.info(`Author search complete: "${name}" → ${authors.length} results`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
authors,
|
||||
query: name.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to search authors', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: 'SearchError', message: 'Failed to search authors' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,49 @@ import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.BookDate.TestConnection');
|
||||
|
||||
// Fetch available Claude models from the Anthropic API
|
||||
async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: string }[]> {
|
||||
const allModels: { id: string; name: string }[] = [];
|
||||
let afterId: string | undefined;
|
||||
|
||||
// Paginate through all available models
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: '1000' });
|
||||
if (afterId) {
|
||||
params.set('after_id', afterId);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.anthropic.com/v1/models?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Claude API error', { error: errorText });
|
||||
throw new Error('Invalid Claude API key or connection failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
for (const model of data.data) {
|
||||
allModels.push({
|
||||
id: model.id,
|
||||
name: model.display_name || model.id,
|
||||
});
|
||||
}
|
||||
|
||||
afterId = data.has_more ? data.last_id : undefined;
|
||||
} while (afterId);
|
||||
|
||||
return allModels;
|
||||
}
|
||||
|
||||
// Helper functions for custom provider
|
||||
function isValidBaseUrl(url: string): boolean {
|
||||
try {
|
||||
@@ -141,32 +184,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
|
||||
models = [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||
];
|
||||
|
||||
// Test connection with a simple API call
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': testApiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 10,
|
||||
messages: [{ role: 'user', content: 'Test' }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Claude API error', { error: errorText });
|
||||
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||
try {
|
||||
models = await fetchClaudeModels(testApiKey);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Claude API key or connection failed' },
|
||||
{ status: 400 }
|
||||
@@ -333,32 +354,10 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
|
||||
models = [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||
];
|
||||
|
||||
// Test connection with a simple API call
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 10,
|
||||
messages: [{ role: 'user', content: 'Test' }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Claude API error', { error: errorText });
|
||||
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||
try {
|
||||
models = await fetchClaudeModels(apiKey);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Claude API key or connection failed' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -14,6 +14,8 @@ import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||
import type { AudibleRegion } from '@/lib/types/audible';
|
||||
import {
|
||||
searchByAsin,
|
||||
searchByTitle,
|
||||
@@ -121,6 +123,11 @@ export async function POST(
|
||||
const format = preferredFormat || 'epub';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||
|
||||
// Get language code from Audible region config
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
const languageCode = langConfig.annasArchiveLang;
|
||||
|
||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
||||
@@ -145,7 +152,8 @@ export async function POST(
|
||||
audiobook.author,
|
||||
format,
|
||||
annasBaseUrl,
|
||||
flaresolverrUrl || undefined
|
||||
flaresolverrUrl || undefined,
|
||||
languageCode
|
||||
).catch((err) => {
|
||||
logger.error(`Anna's Archive search failed: ${err.message}`);
|
||||
return null;
|
||||
@@ -217,7 +225,8 @@ async function searchAnnasArchiveForInteractive(
|
||||
author: string,
|
||||
preferredFormat: string,
|
||||
baseUrl: string,
|
||||
flaresolverrUrl?: string
|
||||
flaresolverrUrl?: string,
|
||||
languageCode: string = 'en'
|
||||
): Promise<EbookSearchResult[]> {
|
||||
let md5: string | null = null;
|
||||
let searchMethod: 'asin' | 'title' = 'title';
|
||||
@@ -225,7 +234,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Try ASIN search first
|
||||
if (asin) {
|
||||
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||
if (md5) {
|
||||
searchMethod = 'asin';
|
||||
logger.info(`Found via ASIN: ${md5}`);
|
||||
@@ -235,7 +244,7 @@ async function searchAnnasArchiveForInteractive(
|
||||
// Fallback to title search
|
||||
if (!md5) {
|
||||
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
|
||||
if (md5) {
|
||||
logger.info(`Found via title: ${md5}`);
|
||||
}
|
||||
@@ -356,6 +365,10 @@ async function searchIndexersForInteractive(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get language-specific stop words for ranking
|
||||
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const rankLangConfig = getLanguageForRegion(rankRegion);
|
||||
|
||||
// Rank results with ebook scoring
|
||||
// Use requireAuthor=false for interactive mode (let user decide)
|
||||
const rankedResults = rankEbookTorrents(allResults, {
|
||||
@@ -366,6 +379,8 @@ async function searchIndexersForInteractive(
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false,
|
||||
stopWords: rankLangConfig.stopWords,
|
||||
characterReplacements: rankLangConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// Log ranking debug info (same format as search-ebook.processor.ts)
|
||||
|
||||
@@ -8,6 +8,9 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||
import { getLanguageForRegion } from '@/lib/constants/language-config';
|
||||
import type { AudibleRegion } from '@/lib/types/audible';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
|
||||
|
||||
@@ -97,9 +100,8 @@ export async function POST(
|
||||
}
|
||||
|
||||
const indexersConfig = JSON.parse(indexersConfigStr);
|
||||
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
|
||||
|
||||
if (enabledIndexerIds.length === 0) {
|
||||
if (indexersConfig.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
|
||||
{ status: 400 }
|
||||
@@ -115,22 +117,53 @@ export async function POST(
|
||||
const flagConfigStr = await configService.get('indexer_flag_config');
|
||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||
|
||||
// Search Prowlarr for torrents - ONLY enabled indexers
|
||||
const prowlarr = await getProwlarrService();
|
||||
// Use custom title if provided, otherwise use audiobook's title
|
||||
const searchQuery = customTitle || requestRecord.audiobook.title;
|
||||
// Group indexers by their category configuration
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
|
||||
|
||||
logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`, { searchQuery });
|
||||
if (skippedIndexers.length > 0) {
|
||||
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
||||
}
|
||||
|
||||
// Use custom title if provided, otherwise use audiobook's title
|
||||
const searchTitle = customTitle || requestRecord.audiobook.title;
|
||||
const searchAuthor = requestRecord.audiobook.author;
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
|
||||
if (customTitle) {
|
||||
logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title });
|
||||
}
|
||||
|
||||
const results = await prowlarr.search(searchQuery, {
|
||||
indexerIds: enabledIndexerIds,
|
||||
maxResults: 100, // Increased limit for broader search
|
||||
// Log each group for transparency
|
||||
groups.forEach((group, index) => {
|
||||
logger.debug(`Group ${index + 1}: ${getGroupDescription(group)}`);
|
||||
});
|
||||
|
||||
logger.debug(`Found ${results.length} raw results`, { requestId: id });
|
||||
// Search Prowlarr for each group and combine results
|
||||
const prowlarr = await getProwlarrService();
|
||||
const allResults = [];
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||
|
||||
try {
|
||||
const groupResults = await prowlarr.searchWithVariations(searchTitle, searchAuthor, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
maxResults: 100,
|
||||
});
|
||||
|
||||
logger.debug(`Group ${i + 1} returned ${groupResults.length} results`);
|
||||
allResults.push(...groupResults);
|
||||
} catch (error) {
|
||||
logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
// Continue with other groups even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
const results = allResults;
|
||||
logger.info(`Found ${results.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`, { requestId: id });
|
||||
|
||||
if (results.length === 0) {
|
||||
return NextResponse.json({
|
||||
@@ -140,16 +173,41 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch runtime from Audnexus if ASIN available (for size-based scoring)
|
||||
let durationMinutes: number | undefined;
|
||||
if (requestRecord.audiobook.audibleAsin) {
|
||||
try {
|
||||
const { getAudibleService } = await import('@/lib/integrations/audible.service');
|
||||
const audibleService = getAudibleService();
|
||||
const runtime = await audibleService.getRuntime(requestRecord.audiobook.audibleAsin);
|
||||
if (runtime) {
|
||||
durationMinutes = runtime;
|
||||
logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${requestRecord.audiobook.audibleAsin}`);
|
||||
} else {
|
||||
logger.debug(`No runtime found for ASIN ${requestRecord.audiobook.audibleAsin}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to fetch runtime for ASIN ${requestRecord.audiobook.audibleAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get language-specific stop words for ranking
|
||||
const region = await configService.getAudibleRegion() as AudibleRegion;
|
||||
const langConfig = getLanguageForRegion(region);
|
||||
|
||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||
// Always use the audiobook's title/author for ranking (not custom search query)
|
||||
// requireAuthor: false - interactive mode, show all results for user decision
|
||||
const rankedResults = rankTorrents(results, {
|
||||
title: requestRecord.audiobook.title,
|
||||
author: requestRecord.audiobook.author,
|
||||
durationMinutes,
|
||||
}, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false // Interactive mode - let user decide
|
||||
requireAuthor: false, // Interactive mode - let user decide
|
||||
stopWords: langConfig.stopWords,
|
||||
characterReplacements: langConfig.characterReplacements,
|
||||
});
|
||||
|
||||
// No threshold filtering for interactive search - show all results
|
||||
@@ -160,17 +218,23 @@ export async function POST(
|
||||
const top3 = rankedResults.slice(0, 3);
|
||||
if (top3.length > 0) {
|
||||
logger.debug('==================== RANKING DEBUG ====================');
|
||||
logger.debug('Search parameters', { searchQuery, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
||||
logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
||||
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
|
||||
logger.debug('--------------------------------------------------------');
|
||||
top3.forEach((result, index) => {
|
||||
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
|
||||
const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A';
|
||||
|
||||
logger.debug(`${index + 1}. "${result.title}"`, {
|
||||
indexer: result.indexer,
|
||||
indexerId: result.indexerId,
|
||||
baseScore: `${result.score.toFixed(1)}/100`,
|
||||
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`,
|
||||
formatScore: `${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`,
|
||||
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`,
|
||||
formatScore: `${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`,
|
||||
sizeScore: durationMinutes
|
||||
? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min)`
|
||||
: 'N/A (no runtime)',
|
||||
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`,
|
||||
bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
|
||||
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
|
||||
finalScore: result.finalScore.toFixed(1),
|
||||
|
||||
+17
-259
@@ -6,11 +6,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Requests');
|
||||
|
||||
@@ -45,274 +43,34 @@ export async function POST(request: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { audiobook } = CreateRequestSchema.parse(body);
|
||||
|
||||
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
|
||||
// This catches the gap where files are organized but Plex hasn't scanned yet
|
||||
const existingActiveRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobook: {
|
||||
audibleAsin: audiobook.asin,
|
||||
},
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
|
||||
|
||||
if (existingActiveRequest) {
|
||||
const status = existingActiveRequest.status;
|
||||
const isOwnRequest = existingActiveRequest.userId === req.user.id;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
|
||||
message: status === 'available'
|
||||
? 'This audiobook is already available in your Plex library'
|
||||
: 'This audiobook is being processed and will be available soon',
|
||||
requestStatus: status,
|
||||
isOwnRequest,
|
||||
requestedBy: existingActiveRequest.user?.plexUsername,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Second check: Is audiobook already in Plex library? (fallback for non-requested books)
|
||||
const plexMatch = await findPlexMatch({
|
||||
const result = await createRequestForUser(req.user.id, {
|
||||
asin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
});
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
}, { skipAutoSearch });
|
||||
|
||||
if (plexMatch) {
|
||||
if (!result.success) {
|
||||
const statusMap: Record<string, { error: string; status: number }> = {
|
||||
already_available: { error: 'AlreadyAvailable', status: 409 },
|
||||
being_processed: { error: 'BeingProcessed', status: 409 },
|
||||
duplicate: { error: 'DuplicateRequest', status: 409 },
|
||||
user_not_found: { error: 'UserNotFound', status: 404 },
|
||||
};
|
||||
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'AlreadyAvailable',
|
||||
message: 'This audiobook is already available in your Plex library',
|
||||
plexGuid: plexMatch.plexGuid,
|
||||
},
|
||||
{ status: 409 }
|
||||
{ error: mapped.error, message: result.message },
|
||||
{ status: mapped.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch full details from Audnexus to get releaseDate, year, and series
|
||||
let year: number | undefined;
|
||||
let series: string | undefined;
|
||||
let seriesPart: string | undefined;
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
|
||||
|
||||
if (audnexusData?.releaseDate) {
|
||||
try {
|
||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
year = releaseYear;
|
||||
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract series data
|
||||
if (audnexusData?.series) {
|
||||
series = audnexusData.series;
|
||||
logger.debug(`Extracted series: ${series}`);
|
||||
}
|
||||
if (audnexusData?.seriesPart) {
|
||||
seriesPart = audnexusData.seriesPart;
|
||||
logger.debug(`Extracted seriesPart: ${seriesPart}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Try to find existing audiobook record by ASIN
|
||||
let audiobookRecord = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: audiobook.asin },
|
||||
});
|
||||
|
||||
// If not found, create new audiobook record
|
||||
if (!audiobookRecord) {
|
||||
audiobookRecord = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
year,
|
||||
series,
|
||||
seriesPart,
|
||||
status: 'requested',
|
||||
},
|
||||
});
|
||||
logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`);
|
||||
} else if (year || series || seriesPart) {
|
||||
// Always update year/series if we have them from Audnexus (even if audiobook already has them)
|
||||
audiobookRecord = await prisma.audiobook.update({
|
||||
where: { id: audiobookRecord.id },
|
||||
data: {
|
||||
...(year && { year }),
|
||||
...(series && { series }),
|
||||
...(seriesPart && { seriesPart }),
|
||||
},
|
||||
});
|
||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
|
||||
}
|
||||
|
||||
// Check if user already has an active (non-deleted) audiobook request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
deletedAt: null, // Only check active requests
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRequest) {
|
||||
// Allow re-requesting if the status is failed, warn, or cancelled
|
||||
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
|
||||
|
||||
if (!canReRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'DuplicateRequest',
|
||||
message: 'You have already requested this audiobook',
|
||||
request: existingRequest,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the existing failed/warn/cancelled request
|
||||
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
|
||||
await prisma.request.delete({
|
||||
where: { id: existingRequest.id },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we should skip auto-search (for interactive search)
|
||||
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
|
||||
|
||||
// Check if request needs approval
|
||||
let needsApproval = false;
|
||||
let shouldTriggerSearch = !skipAutoSearch;
|
||||
|
||||
// Fetch user with autoApproveRequests setting
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'UserNotFound', message: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if approval is needed
|
||||
if (user.role === 'admin') {
|
||||
// Admins always auto-approve
|
||||
needsApproval = false;
|
||||
} else {
|
||||
// Check user's personal setting first
|
||||
if (user.autoApproveRequests === true) {
|
||||
needsApproval = false;
|
||||
} else if (user.autoApproveRequests === false) {
|
||||
needsApproval = true;
|
||||
} else {
|
||||
// User setting is null, check global setting
|
||||
const globalConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'auto_approve_requests' },
|
||||
});
|
||||
// Default to true if not configured (backward compatibility)
|
||||
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
|
||||
needsApproval = !globalAutoApprove;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine initial status
|
||||
let initialStatus: string;
|
||||
if (needsApproval) {
|
||||
initialStatus = 'awaiting_approval';
|
||||
shouldTriggerSearch = false; // Don't trigger search if awaiting approval
|
||||
} else if (skipAutoSearch) {
|
||||
initialStatus = 'awaiting_search';
|
||||
} else {
|
||||
initialStatus = 'pending';
|
||||
}
|
||||
|
||||
// Create request with appropriate status
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: initialStatus,
|
||||
type: 'audiobook', // Explicit type for user-created requests
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// Send notification based on approval status
|
||||
if (initialStatus === 'awaiting_approval') {
|
||||
// Request needs approval - send pending notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_pending_approval',
|
||||
newRequest.id,
|
||||
audiobookRecord.title,
|
||||
audiobookRecord.author,
|
||||
newRequest.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
} else {
|
||||
// Request was auto-approved (either automatic or interactive search) - send approved notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
newRequest.id,
|
||||
audiobookRecord.title,
|
||||
audiobookRecord.author,
|
||||
newRequest.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger search job only if not skipped and not awaiting approval
|
||||
if (shouldTriggerSearch) {
|
||||
await jobQueue.addSearchJob(newRequest.id, {
|
||||
id: audiobookRecord.id,
|
||||
title: audiobookRecord.title,
|
||||
author: audiobookRecord.author,
|
||||
asin: audiobookRecord.audibleAsin || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: newRequest,
|
||||
request: result.request,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Component: Series Detail API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
|
||||
const logger = RMABLogger.create('API.Series.Detail');
|
||||
|
||||
/**
|
||||
* GET /api/series/{asin}
|
||||
* Fetch series detail: metadata + books (enriched with availability) + similar series
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
try {
|
||||
const currentUser = getCurrentUser(request);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { asin } = await params;
|
||||
|
||||
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Valid series ASIN is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Fetching series detail: ${asin}`);
|
||||
|
||||
const detail = await scrapeSeriesPage(asin);
|
||||
if (!detail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Series not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich books with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
|
||||
|
||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books)`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series: {
|
||||
...detail,
|
||||
books: enrichedBooks,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch series detail', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch series details' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Component: Series Search API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { searchForSeries } from '@/lib/integrations/audible-series';
|
||||
|
||||
const logger = RMABLogger.create('API.Series.Search');
|
||||
|
||||
/**
|
||||
* GET /api/series/search?q=game+of+thrones
|
||||
* Search for audiobook series on Audible, de-duplicate, and return enriched summaries
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Require authentication
|
||||
const currentUser = getCurrentUser(request);
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const query = request.nextUrl.searchParams.get('q');
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Search query is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Searching series: "${query}"`);
|
||||
|
||||
const series = await searchForSeries(query.trim());
|
||||
|
||||
logger.info(`Series search complete: "${query}" -> ${series.length} results`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series,
|
||||
query: query.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to search series', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'SearchError', message: 'Failed to search series' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Component: Setup Wizard Download Client Categories API
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
|
||||
import { requireSetupIncomplete } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Setup.DownloadClientCategories');
|
||||
|
||||
/**
|
||||
* POST - Fetch categories from a download client during setup wizard
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireSetupIncomplete(request, async (req) => {
|
||||
try {
|
||||
const { type, name, url, username, password, disableSSLVerify } = await req.json();
|
||||
|
||||
if (!type || !url) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Type and URL are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const testConfig: DownloadClientConfig = {
|
||||
id: 'setup-categories',
|
||||
type,
|
||||
name: name || type,
|
||||
enabled: true,
|
||||
url,
|
||||
username: username || '',
|
||||
password: password || '',
|
||||
disableSSLVerify: disableSSLVerify || false,
|
||||
remotePathMappingEnabled: false,
|
||||
};
|
||||
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const service = await manager.createClientFromConfig(testConfig);
|
||||
const categories = await service.getCategories();
|
||||
|
||||
return NextResponse.json({ success: true, categories });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch categories', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to fetch categories' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Component: Goodreads Shelf Delete Route
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.GoodreadsShelves');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/goodreads-shelves/[id]
|
||||
* Remove a Goodreads shelf subscription (ownership check)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const shelf = await prisma.goodreadsShelf.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!shelf) {
|
||||
return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Ownership check
|
||||
if (shelf.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.goodreadsShelf.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete shelf', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to delete shelf' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Component: Goodreads Shelves API Routes
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fetchAndValidateRss } from '@/lib/services/goodreads-sync.service';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.GoodreadsShelves');
|
||||
|
||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||
|
||||
const AddShelfSchema = z.object({
|
||||
rssUrl: z.string().url().refine(
|
||||
(url) => GOODREADS_RSS_PATTERN.test(url),
|
||||
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/goodreads-shelves
|
||||
* List the current user's Goodreads shelves with book counts and covers
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const shelves = await prisma.goodreadsShelf.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const shelvesWithMeta = shelves.map((shelf) => {
|
||||
// Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
|
||||
let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = [];
|
||||
if (shelf.coverUrls) {
|
||||
const parsed = JSON.parse(shelf.coverUrls);
|
||||
if (Array.isArray(parsed)) {
|
||||
books = parsed.map((item: unknown) => {
|
||||
if (typeof item === 'string') {
|
||||
return { coverUrl: item, asin: null, title: '', author: '' };
|
||||
}
|
||||
const obj = item as Record<string, unknown>;
|
||||
return {
|
||||
coverUrl: (obj.coverUrl as string) || '',
|
||||
asin: (obj.asin as string) || null,
|
||||
title: (obj.title as string) || '',
|
||||
author: (obj.author as string) || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: shelf.id,
|
||||
name: shelf.name,
|
||||
rssUrl: shelf.rssUrl,
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount ?? null,
|
||||
books,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/goodreads-shelves
|
||||
* Add a new Goodreads shelf subscription
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { rssUrl } = AddShelfSchema.parse(body);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.goodreadsShelf.findUnique({
|
||||
where: { userId_rssUrl: { userId: req.user.id, rssUrl } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'DuplicateShelf', message: 'You have already added this shelf' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate by fetching the RSS feed
|
||||
let shelfName: string;
|
||||
let bookCount: number;
|
||||
let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = [];
|
||||
try {
|
||||
const rssData = await fetchAndValidateRss(rssUrl);
|
||||
shelfName = rssData.shelfName;
|
||||
bookCount = rssData.books.length;
|
||||
initialBooks = rssData.books
|
||||
.filter(b => b.coverUrl)
|
||||
.slice(0, 8)
|
||||
.map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author }));
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InvalidRSS',
|
||||
message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const shelf = await prisma.goodreadsShelf.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
name: shelfName,
|
||||
rssUrl,
|
||||
bookCount,
|
||||
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
|
||||
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
shelf: {
|
||||
id: shelf.id,
|
||||
name: shelf.name,
|
||||
rssUrl: shelf.rssUrl,
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount,
|
||||
books: initialBooks,
|
||||
},
|
||||
bookCount,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Component: Author Detail Page
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { use, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
|
||||
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
|
||||
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function AuthorDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ asin: string }>;
|
||||
}) {
|
||||
const { asin } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const fromAuthorName = searchParams.get('from');
|
||||
const { author, isLoading: authorLoading } = useAuthorDetail(asin);
|
||||
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks(
|
||||
asin,
|
||||
author?.name || null
|
||||
);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
// Use browser back if we came from within the app, otherwise fallback to /authors
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/authors');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8">
|
||||
{/* Back navigation */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{fromAuthorName ? `Back to ${fromAuthorName}` : 'Back to Authors'}
|
||||
</button>
|
||||
|
||||
{/* Author Detail Card */}
|
||||
{authorLoading ? (
|
||||
<AuthorDetailSkeleton />
|
||||
) : author ? (
|
||||
<AuthorDetailCard author={author} />
|
||||
) : (
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg className="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">Author not found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Similar Authors */}
|
||||
{authorLoading ? (
|
||||
<SimilarAuthorsSkeleton />
|
||||
) : author && author.similar.length > 0 ? (
|
||||
<SimilarAuthorsRow authors={author.similar} currentAuthorName={author.name} />
|
||||
) : null}
|
||||
|
||||
{/* Books Section */}
|
||||
{author && (
|
||||
<div className="space-y-6">
|
||||
{/* Sticky Books Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Books
|
||||
</h2>
|
||||
{!booksLoading && totalBooks > 0 && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({totalBooks} title{totalBooks !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Books Grid */}
|
||||
<AudiobookGrid
|
||||
audiobooks={books}
|
||||
isLoading={booksLoading}
|
||||
emptyMessage={`No books found for ${author.name}`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Component: Authors Page
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AuthorGrid } from '@/components/authors/AuthorGrid';
|
||||
import { useAuthorSearch } from '@/lib/hooks/useAuthors';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
function AuthorsPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const initialQuery = searchParams.get('q') || '';
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||
const { cardSize, setCardSize } = usePreferences();
|
||||
|
||||
// Debounce search query and sync to URL
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
// Update URL without adding history entries
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) {
|
||||
router.replace(`/authors?q=${encodeURIComponent(trimmed)}`, { scroll: false });
|
||||
} else {
|
||||
router.replace('/authors', { scroll: false });
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, router]);
|
||||
|
||||
const { authors, isLoading } = useAuthorSearch(debouncedQuery);
|
||||
|
||||
const handleSearch = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Browse Authors
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Search for your favorite audiobook authors
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by author name..."
|
||||
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
autoFocus
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Results */}
|
||||
{debouncedQuery ? (
|
||||
<div className="space-y-6">
|
||||
{/* Sticky Results Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Authors
|
||||
</h2>
|
||||
{!isLoading && authors.length > 0 && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({authors.length} result{authors.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Grid */}
|
||||
<AuthorGrid
|
||||
authors={authors}
|
||||
isLoading={!!isLoading}
|
||||
emptyMessage={`No authors found for "${debouncedQuery}"`}
|
||||
cardSize={cardSize}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||
Start typing to search for authors
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
Search by author name to discover their works
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthorsPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AuthorsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -196,3 +196,12 @@ body {
|
||||
.animate-toast-in {
|
||||
animation: toast-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Hide scrollbar while keeping scroll functional */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ function LoginContent() {
|
||||
hasLocalUsers: boolean;
|
||||
oidcProviderName: string | null;
|
||||
localLoginDisabled: boolean;
|
||||
allowWeakPassword: boolean;
|
||||
automationEnabled: boolean;
|
||||
} | null>(null);
|
||||
const [showRegisterForm, setShowRegisterForm] = useState(false);
|
||||
@@ -78,6 +79,7 @@ function LoginContent() {
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled: false,
|
||||
allowWeakPassword: false,
|
||||
automationEnabled: false,
|
||||
});
|
||||
}
|
||||
@@ -345,7 +347,7 @@ function LoginContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (registerPassword.length < 8) {
|
||||
if (!authProviders?.allowWeakPassword && registerPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
setIsLoggingIn(false);
|
||||
return;
|
||||
@@ -639,10 +641,12 @@ function LoginContent() {
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
minLength={authProviders?.allowWeakPassword ? 1 : 8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
|
||||
{!authProviders?.allowWeakPassword && (
|
||||
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
@@ -656,7 +660,7 @@ function LoginContent() {
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
minLength={authProviders?.allowWeakPassword ? 1 : 8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+124
-252
@@ -11,80 +11,63 @@ import { RequestCard } from '@/components/requests/RequestCard';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRequests } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||
|
||||
const statConfig = [
|
||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||
{ key: 'active', label: 'Active', color: 'text-blue-500' },
|
||||
{ key: 'waiting', label: 'Waiting', color: 'text-amber-500' },
|
||||
{ key: 'completed', label: 'Complete', color: 'text-emerald-500' },
|
||||
{ key: 'failed', label: 'Failed', color: 'text-red-500' },
|
||||
{ key: 'cancelled', label: 'Cancelled', color: 'text-gray-400 dark:text-gray-500' },
|
||||
] as const;
|
||||
|
||||
type StatKey = (typeof statConfig)[number]['key'];
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user } = useAuth();
|
||||
// Always show only the current user's own requests (even for admins)
|
||||
const { requests, isLoading } = useRequests(undefined, 50, true);
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
if (!requests.length) {
|
||||
return {
|
||||
total: 0,
|
||||
completed: 0,
|
||||
active: 0,
|
||||
waiting: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
};
|
||||
return { total: 0, completed: 0, active: 0, waiting: 0, failed: 0, cancelled: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
total: requests.length,
|
||||
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
|
||||
active: requests.filter((r: any) =>
|
||||
['pending', 'searching', 'downloading', 'processing'].includes(r.status)
|
||||
).length,
|
||||
waiting: requests.filter((r: any) =>
|
||||
['awaiting_search', 'awaiting_import'].includes(r.status)
|
||||
).length,
|
||||
active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length,
|
||||
waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length,
|
||||
failed: requests.filter((r: any) => r.status === 'failed').length,
|
||||
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
|
||||
};
|
||||
}, [requests]);
|
||||
|
||||
// Get active downloads (downloading or processing)
|
||||
const activeDownloads = useMemo(() => {
|
||||
return requests.filter((r: any) =>
|
||||
['downloading', 'processing'].includes(r.status)
|
||||
);
|
||||
return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status));
|
||||
}, [requests]);
|
||||
|
||||
// Get recent requests (last 5)
|
||||
const recentRequests = useMemo(() => {
|
||||
return [...requests]
|
||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 5);
|
||||
}, [requests]);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
<main className="container mx-auto px-4 py-20 max-w-5xl text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mx-auto mb-5">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Authentication Required
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Please log in to view your profile
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Sign in required
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Please log in to view your profile
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
@@ -94,183 +77,83 @@ export default function ProfilePage() {
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
|
||||
{/* User Info Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
|
||||
<main className="container mx-auto px-4 py-8 max-w-5xl space-y-10">
|
||||
{/* Profile Card — gradient banner + avatar + info + stats */}
|
||||
<section className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||
{/* Gradient Banner */}
|
||||
<div className="h-32 sm:h-40 bg-gradient-to-br from-blue-600 via-indigo-500 to-violet-600" />
|
||||
|
||||
{/* Profile Content — overlapping the banner */}
|
||||
<div className="px-6 sm:px-8 pb-8 -mt-14 sm:-mt-16">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.username}
|
||||
className="w-24 h-24 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center text-white text-3xl font-bold">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.username}
|
||||
className="w-28 h-28 rounded-full ring-4 ring-white dark:ring-gray-800 shadow-lg object-cover mb-5"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-4xl font-bold ring-4 ring-white dark:ring-gray-800 shadow-lg mb-5">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Details */}
|
||||
<div className="flex-1 space-y-2 text-center sm:text-left">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{user.username}
|
||||
</h1>
|
||||
{user.email && (
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{user.role === 'admin' ? 'Administrator' : 'User'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-500">
|
||||
Plex ID: {user.plexId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
|
||||
{/* Total Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{isLoading ? '...' : stats.total}
|
||||
</p>
|
||||
</div>
|
||||
{/* Name + Email + Badge */}
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{user.username}
|
||||
</h1>
|
||||
{user.email && (
|
||||
<p className="text-base text-gray-500 dark:text-gray-400 mt-1">
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide',
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-50 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{user.role === 'admin' ? 'Administrator' : 'User'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{/* Stats Strip */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-px bg-gray-100 dark:bg-gray-700/30">
|
||||
{statConfig.map((stat) => (
|
||||
<div
|
||||
key={stat.key}
|
||||
className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800"
|
||||
>
|
||||
<div className={cn('text-2xl sm:text-3xl font-bold tabular-nums', stat.color)}>
|
||||
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mt-1.5">
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Active</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{isLoading ? '...' : stats.active}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Waiting Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Waiting</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{isLoading ? '...' : stats.waiting}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completed Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Completed</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{isLoading ? '...' : stats.completed}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Failed</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{isLoading ? '...' : stats.failed}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancelled Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Cancelled</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-gray-600 dark:text-gray-400">
|
||||
{isLoading ? '...' : stats.cancelled}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Goodreads Shelves */}
|
||||
<GoodreadsShelvesSection />
|
||||
|
||||
{/* Active Downloads */}
|
||||
{activeDownloads.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Active Downloads
|
||||
</h2>
|
||||
<a
|
||||
href="/requests"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
View All Requests →
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
@@ -278,21 +161,23 @@ export default function ProfilePage() {
|
||||
<RequestCard key={request.id} request={request} showActions={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recent Requests */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Recent Requests
|
||||
</h2>
|
||||
<a
|
||||
href="/requests"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
View All Requests →
|
||||
</a>
|
||||
{requests.length > 0 && (
|
||||
<a
|
||||
href="/requests"
|
||||
className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
View All
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -300,14 +185,14 @@ export default function ProfilePage() {
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
|
||||
className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-5 animate-pulse"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div>
|
||||
<div className="w-20 h-28 bg-gray-100 dark:bg-gray-700/50 rounded-lg flex-shrink-0" />
|
||||
<div className="flex-1 space-y-3 py-1">
|
||||
<div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-3/4" />
|
||||
<div className="h-4 bg-gray-100 dark:bg-gray-700/50 rounded w-1/2" />
|
||||
<div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,47 +205,34 @@ export default function ProfilePage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow-md space-y-4">
|
||||
<div className="rounded-2xl border-2 border-dashed border-gray-200 dark:border-gray-700/50 py-16 text-center">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-gray-400"
|
||||
className="mx-auto w-10 h-10 text-gray-300 dark:text-gray-600 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
|
||||
</svg>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
No requests yet
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Start by searching for audiobooks and requesting them
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<a
|
||||
href="/search"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
Search Audiobooks
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-base font-medium text-gray-500 dark:text-gray-400">
|
||||
No requests yet
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-600 mt-1">
|
||||
Search for audiobooks to get started
|
||||
</p>
|
||||
<a
|
||||
href="/search"
|
||||
className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
Search Audiobooks
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Component: Series Detail Page
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { use, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
|
||||
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
|
||||
import { useSeriesDetail } from '@/lib/hooks/useSeries';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
export default function SeriesDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ asin: string }>;
|
||||
}) {
|
||||
const { asin } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const fromSeriesTitle = searchParams.get('from');
|
||||
const { series, isLoading: seriesLoading } = useSeriesDetail(asin);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
// Use browser back if we came from within the app, otherwise fallback to /series
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/series');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8">
|
||||
{/* Back navigation */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{fromSeriesTitle ? `Back to ${fromSeriesTitle}` : 'Back to Series'}
|
||||
</button>
|
||||
|
||||
{/* Series Detail Card */}
|
||||
{seriesLoading ? (
|
||||
<SeriesDetailSkeleton squareCovers={squareCovers} />
|
||||
) : series ? (
|
||||
<SeriesDetailCard series={series} squareCovers={squareCovers} />
|
||||
) : (
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg className="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">Series not found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Similar Series */}
|
||||
{seriesLoading ? (
|
||||
<SimilarSeriesSkeleton squareCovers={squareCovers} />
|
||||
) : series && series.similarSeries.length > 0 ? (
|
||||
<SimilarSeriesRow series={series.similarSeries} currentSeriesTitle={series.title} squareCovers={squareCovers} />
|
||||
) : null}
|
||||
|
||||
{/* Books Section */}
|
||||
{series && (
|
||||
<div className="space-y-6">
|
||||
{/* Sticky Books Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Books in Series
|
||||
</h2>
|
||||
{series.books.length > 0 && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({series.books.length} title{series.books.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Books Grid */}
|
||||
<AudiobookGrid
|
||||
audiobooks={series.books}
|
||||
isLoading={seriesLoading}
|
||||
emptyMessage={`No books found for ${series.title}`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Component: Series Page
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { SeriesGrid } from '@/components/series/SeriesGrid';
|
||||
import { useSeriesSearch } from '@/lib/hooks/useSeries';
|
||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||
import { CardSizeControls } from '@/components/ui/CardSizeControls';
|
||||
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
function SeriesPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const initialQuery = searchParams.get('q') || '';
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
|
||||
|
||||
// Debounce search query and sync to URL
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
// Update URL without adding history entries
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) {
|
||||
router.replace(`/series?q=${encodeURIComponent(trimmed)}`, { scroll: false });
|
||||
} else {
|
||||
router.replace('/series', { scroll: false });
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, router]);
|
||||
|
||||
const { series, isLoading } = useSeriesSearch(debouncedQuery);
|
||||
|
||||
const handleSearch = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Browse Series
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Search for your favorite audiobook series
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by series name..."
|
||||
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
autoFocus
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Results */}
|
||||
{debouncedQuery ? (
|
||||
<div className="space-y-6">
|
||||
{/* Sticky Results Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
Series
|
||||
</h2>
|
||||
{!isLoading && series.length > 0 && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
|
||||
({series.length} result{series.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
|
||||
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Series Grid */}
|
||||
<SeriesGrid
|
||||
series={series}
|
||||
isLoading={!!isLoading}
|
||||
emptyMessage={`No series found for "${debouncedQuery}"`}
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<div className="text-center py-16 space-y-4">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||
Start typing to search for series
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
Search by series name to discover audiobook collections
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SeriesPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SeriesPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,13 @@ import { AudibleRegion } from '@/lib/types/audible';
|
||||
interface SelectedIndexer {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes?: number;
|
||||
removeAfterProcessing?: boolean;
|
||||
rssEnabled: boolean;
|
||||
audiobookCategories: number[];
|
||||
ebookCategories: number[];
|
||||
}
|
||||
|
||||
interface SetupState {
|
||||
@@ -86,6 +92,14 @@ interface SetupState {
|
||||
bookdateApiKey: string;
|
||||
bookdateModel: string;
|
||||
bookdateConfigured: boolean;
|
||||
|
||||
// Cached UI state for back-navigation persistence
|
||||
plexLibraries: { id: string; title: string; type: string }[];
|
||||
absLibraries: { id: string; name: string; itemCount: number }[];
|
||||
oidcTested: boolean;
|
||||
pathsTested: boolean;
|
||||
bookdateModels: { id: string; name: string }[];
|
||||
|
||||
validated: {
|
||||
plex: boolean;
|
||||
prowlarr: boolean;
|
||||
@@ -152,6 +166,14 @@ export default function SetupWizard() {
|
||||
bookdateApiKey: '',
|
||||
bookdateModel: '',
|
||||
bookdateConfigured: false,
|
||||
|
||||
// Cached UI state for back-navigation persistence
|
||||
plexLibraries: [],
|
||||
absLibraries: [],
|
||||
oidcTested: false,
|
||||
pathsTested: false,
|
||||
bookdateModels: [],
|
||||
|
||||
validated: {
|
||||
plex: false,
|
||||
prowlarr: false,
|
||||
@@ -379,6 +401,7 @@ export default function SetupWizard() {
|
||||
plexToken={state.plexToken}
|
||||
plexLibraryId={state.plexLibraryId}
|
||||
plexTriggerScanAfterImport={state.plexTriggerScanAfterImport}
|
||||
plexLibraries={state.plexLibraries}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -397,6 +420,7 @@ export default function SetupWizard() {
|
||||
absApiToken={state.absApiToken}
|
||||
absLibraryId={state.absLibraryId}
|
||||
absTriggerScanAfterImport={state.absTriggerScanAfterImport}
|
||||
absLibraries={state.absLibraries}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -435,6 +459,7 @@ export default function SetupWizard() {
|
||||
oidcAdminClaimEnabled={state.oidcAdminClaimEnabled}
|
||||
oidcAdminClaimName={state.oidcAdminClaimName}
|
||||
oidcAdminClaimValue={state.oidcAdminClaimValue}
|
||||
oidcTested={state.oidcTested}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -482,6 +507,7 @@ export default function SetupWizard() {
|
||||
<ProwlarrStep
|
||||
prowlarrUrl={state.prowlarrUrl}
|
||||
prowlarrApiKey={state.prowlarrApiKey}
|
||||
prowlarrIndexers={state.prowlarrIndexers}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -512,6 +538,7 @@ export default function SetupWizard() {
|
||||
mediaDir={state.mediaDir}
|
||||
metadataTaggingEnabled={state.metadataTaggingEnabled}
|
||||
chapterMergingEnabled={state.chapterMergingEnabled}
|
||||
pathsTested={state.pathsTested}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -528,6 +555,7 @@ export default function SetupWizard() {
|
||||
bookdateApiKey={state.bookdateApiKey}
|
||||
bookdateModel={state.bookdateModel}
|
||||
bookdateConfigured={state.bookdateConfigured}
|
||||
bookdateModels={state.bookdateModels}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onSkip={() => goToStep(currentStepNumber + 1)}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface AdminAccountStepProps {
|
||||
@@ -25,6 +25,23 @@ export function AdminAccountStep({
|
||||
}: AdminAccountStepProps) {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [errors, setErrors] = useState<{ username?: string; password?: string; confirm?: string }>({});
|
||||
const [allowWeakPassword, setAllowWeakPassword] = useState(false);
|
||||
|
||||
// Fetch password policy
|
||||
useEffect(() => {
|
||||
const fetchPolicy = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/providers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAllowWeakPassword(data.allowWeakPassword === true);
|
||||
}
|
||||
} catch {
|
||||
// Default to strict validation on error
|
||||
}
|
||||
};
|
||||
fetchPolicy();
|
||||
}, []);
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: { username?: string; password?: string; confirm?: string } = {};
|
||||
@@ -35,7 +52,9 @@ export function AdminAccountStep({
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if (!adminPassword || adminPassword.length < 8) {
|
||||
if (!adminPassword) {
|
||||
newErrors.password = 'Password is required';
|
||||
} else if (!allowWeakPassword && adminPassword.length < 8) {
|
||||
newErrors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
@@ -104,7 +123,7 @@ export function AdminAccountStep({
|
||||
<p className="mt-1 text-sm text-red-400">{errors.password}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Choose a strong password (minimum 8 characters)
|
||||
{allowWeakPassword ? 'Choose a password' : 'Choose a strong password (minimum 8 characters)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ interface AudiobookshelfStepProps {
|
||||
absApiToken: string;
|
||||
absLibraryId: string;
|
||||
absTriggerScanAfterImport: boolean;
|
||||
onUpdate: (field: string, value: string | boolean) => void;
|
||||
absLibraries: Library[];
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -30,6 +31,7 @@ export function AudiobookshelfStep({
|
||||
absApiToken,
|
||||
absLibraryId,
|
||||
absTriggerScanAfterImport,
|
||||
absLibraries,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -39,8 +41,12 @@ export function AudiobookshelfStep({
|
||||
success: boolean;
|
||||
message?: string;
|
||||
libraries?: Library[];
|
||||
} | null>(null);
|
||||
const [libraries, setLibraries] = useState<Library[]>([]);
|
||||
} | null>(
|
||||
absLibraries.length > 0
|
||||
? { success: true, message: 'Connection verified previously.' }
|
||||
: null
|
||||
);
|
||||
const [libraries, setLibraries] = useState<Library[]>(absLibraries);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
@@ -56,12 +62,14 @@ export function AudiobookshelfStep({
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
const libs = data.libraries || [];
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: 'Connection successful!',
|
||||
libraries: data.libraries || [],
|
||||
libraries: libs,
|
||||
});
|
||||
setLibraries(data.libraries || []);
|
||||
setLibraries(libs);
|
||||
onUpdate('absLibraries', libs);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
|
||||
@@ -115,11 +115,11 @@ export function BackendSelectionStep({
|
||||
>
|
||||
{Object.values(AUDIBLE_REGIONS).map((region) => (
|
||||
<option key={region.code} value={region.code}>
|
||||
{region.name}{!region.isEnglish ? ' *' : ''}
|
||||
{region.name}{region.language !== 'en' ? ' *' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{AUDIBLE_REGIONS[audibleRegion]?.isEnglish === false && (
|
||||
{AUDIBLE_REGIONS[audibleRegion]?.language !== 'en' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
|
||||
@@ -12,6 +12,7 @@ interface BookDateStepProps {
|
||||
bookdateApiKey: string;
|
||||
bookdateModel: string;
|
||||
bookdateConfigured: boolean;
|
||||
bookdateModels: ModelOption[];
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
@@ -28,6 +29,7 @@ export function BookDateStep({
|
||||
bookdateApiKey,
|
||||
bookdateModel,
|
||||
bookdateConfigured,
|
||||
bookdateModels,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onSkip,
|
||||
@@ -35,7 +37,7 @@ export function BookDateStep({
|
||||
}: BookDateStepProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [tested, setTested] = useState(bookdateConfigured);
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
const [models, setModels] = useState<ModelOption[]>(bookdateModels);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
@@ -65,19 +67,22 @@ export function BookDateStep({
|
||||
throw new Error(data.error || 'Connection test failed');
|
||||
}
|
||||
|
||||
setModels(data.models || []);
|
||||
const fetchedModels = data.models || [];
|
||||
setModels(fetchedModels);
|
||||
setTested(true);
|
||||
onUpdate('bookdateConfigured', true);
|
||||
onUpdate('bookdateModels', fetchedModels);
|
||||
|
||||
// Auto-select first model if none selected
|
||||
if (!bookdateModel && data.models?.length > 0) {
|
||||
onUpdate('bookdateModel', data.models[0].id);
|
||||
if (!bookdateModel && fetchedModels.length > 0) {
|
||||
onUpdate('bookdateModel', fetchedModels[0].id);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Connection test failed');
|
||||
setTested(false);
|
||||
onUpdate('bookdateConfigured', false);
|
||||
onUpdate('bookdateModels', []);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
@@ -123,6 +128,7 @@ export function BookDateStep({
|
||||
setTested(false);
|
||||
setModels([]);
|
||||
onUpdate('bookdateConfigured', false);
|
||||
onUpdate('bookdateModels', []);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
@@ -144,6 +150,7 @@ export function BookDateStep({
|
||||
setTested(false);
|
||||
setModels([]);
|
||||
onUpdate('bookdateConfigured', false);
|
||||
onUpdate('bookdateModels', []);
|
||||
}}
|
||||
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement';
|
||||
import { DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
||||
@@ -24,6 +24,7 @@ interface DownloadClient {
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
postImportCategory?: string;
|
||||
}
|
||||
|
||||
interface DownloadClientStepProps {
|
||||
|
||||
@@ -22,6 +22,7 @@ interface OIDCConfigStepProps {
|
||||
oidcAdminClaimEnabled: boolean;
|
||||
oidcAdminClaimName: string;
|
||||
oidcAdminClaimValue: string;
|
||||
oidcTested: boolean;
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
@@ -40,6 +41,7 @@ export function OIDCConfigStep({
|
||||
oidcAdminClaimEnabled,
|
||||
oidcAdminClaimName,
|
||||
oidcAdminClaimValue,
|
||||
oidcTested,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -48,7 +50,11 @@ export function OIDCConfigStep({
|
||||
const [testResult, setTestResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
} | null>(
|
||||
oidcTested
|
||||
? { success: true, message: 'OIDC configuration verified previously.' }
|
||||
: null
|
||||
);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
@@ -72,17 +78,20 @@ export function OIDCConfigStep({
|
||||
success: true,
|
||||
message: 'OIDC discovery successful! Provider configuration validated.',
|
||||
});
|
||||
onUpdate('oidcTested', true);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: data.error || 'OIDC discovery failed',
|
||||
});
|
||||
onUpdate('oidcTested', false);
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Connection test failed',
|
||||
});
|
||||
onUpdate('oidcTested', false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ interface PathsStepProps {
|
||||
mediaDir: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
onUpdate: (field: string, value: string | boolean) => void;
|
||||
pathsTested: boolean;
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export function PathsStep({
|
||||
mediaDir,
|
||||
metadataTaggingEnabled,
|
||||
chapterMergingEnabled,
|
||||
pathsTested,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -34,7 +36,11 @@ export function PathsStep({
|
||||
message: string;
|
||||
downloadDirValid?: boolean;
|
||||
mediaDirValid?: boolean;
|
||||
} | null>(null);
|
||||
} | null>(
|
||||
pathsTested
|
||||
? { success: true, message: 'Paths validated previously.', downloadDirValid: true, mediaDirValid: true }
|
||||
: null
|
||||
);
|
||||
|
||||
const testPaths = async () => {
|
||||
setTesting(true);
|
||||
@@ -59,6 +65,7 @@ export function PathsStep({
|
||||
downloadDirValid: data.downloadDirValid,
|
||||
mediaDirValid: data.mediaDirValid,
|
||||
});
|
||||
onUpdate('pathsTested', true);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
@@ -66,12 +73,14 @@ export function PathsStep({
|
||||
downloadDirValid: data.downloadDirValid,
|
||||
mediaDirValid: data.mediaDirValid,
|
||||
});
|
||||
onUpdate('pathsTested', false);
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Path validation failed',
|
||||
});
|
||||
onUpdate('pathsTested', false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ interface PlexStepProps {
|
||||
plexToken: string;
|
||||
plexLibraryId: string;
|
||||
plexTriggerScanAfterImport: boolean;
|
||||
onUpdate: (field: string, value: string | boolean) => void;
|
||||
plexLibraries: PlexLibrary[];
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -30,6 +31,7 @@ export function PlexStep({
|
||||
plexToken,
|
||||
plexLibraryId,
|
||||
plexTriggerScanAfterImport,
|
||||
plexLibraries,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -39,8 +41,12 @@ export function PlexStep({
|
||||
success: boolean;
|
||||
message: string;
|
||||
libraries?: PlexLibrary[];
|
||||
} | null>(null);
|
||||
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
|
||||
} | null>(
|
||||
plexLibraries.length > 0
|
||||
? { success: true, message: 'Connection verified previously.' }
|
||||
: null
|
||||
);
|
||||
const [libraries, setLibraries] = useState<PlexLibrary[]>(plexLibraries);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
@@ -56,12 +62,14 @@ export function PlexStep({
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
const libs = data.libraries || [];
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to ${data.serverName || 'Plex server'} successfully!`,
|
||||
libraries: data.libraries || [],
|
||||
libraries: libs,
|
||||
});
|
||||
setLibraries(data.libraries || []);
|
||||
setLibraries(libs);
|
||||
onUpdate('plexLibraries', libs);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||
@@ -13,6 +13,7 @@ import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement
|
||||
interface ProwlarrStepProps {
|
||||
prowlarrUrl: string;
|
||||
prowlarrApiKey: string;
|
||||
prowlarrIndexers: SelectedIndexer[];
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
@@ -33,17 +34,19 @@ interface SelectedIndexer {
|
||||
export function ProwlarrStep({
|
||||
prowlarrUrl,
|
||||
prowlarrApiKey,
|
||||
prowlarrIndexers,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
}: ProwlarrStepProps) {
|
||||
const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>([]);
|
||||
const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>(prowlarrIndexers);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Sync configured indexers with parent
|
||||
useEffect(() => {
|
||||
onUpdate('prowlarrIndexers', configuredIndexers);
|
||||
}, [configuredIndexers, onUpdate]);
|
||||
// Update both local and parent state when indexers change
|
||||
const handleIndexersChange = (indexers: SelectedIndexer[]) => {
|
||||
setConfiguredIndexers(indexers);
|
||||
onUpdate('prowlarrIndexers', indexers);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setErrorMessage(null);
|
||||
@@ -136,7 +139,7 @@ export function ProwlarrStep({
|
||||
prowlarrApiKey={prowlarrApiKey}
|
||||
mode="wizard"
|
||||
initialIndexers={configuredIndexers}
|
||||
onIndexersChange={setConfiguredIndexers}
|
||||
onIndexersChange={handleIndexersChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface DownloadClientCardProps {
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
customPath?: string;
|
||||
postImportCategory?: string;
|
||||
};
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -28,6 +29,7 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
|
||||
transmission: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
|
||||
sabnzbd: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
|
||||
nzbget: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300',
|
||||
deluge: 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300',
|
||||
};
|
||||
const typeColor = typeColorMap[client.type] || typeColorMap.qbittorrent;
|
||||
|
||||
@@ -62,6 +64,11 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
|
||||
Path: {client.customPath}
|
||||
</p>
|
||||
)}
|
||||
{client.postImportCategory && (
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 truncate" title={`Post-import category: ${client.postImportCategory}`}>
|
||||
Post-import: {client.postImportCategory}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ interface DownloadClient {
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
postImportCategory?: string;
|
||||
}
|
||||
|
||||
interface DownloadClientManagementProps {
|
||||
@@ -72,20 +73,6 @@ export function DownloadClientManagement({
|
||||
}
|
||||
}, [downloadDirProp]);
|
||||
|
||||
// Sync with parent when clients change
|
||||
useEffect(() => {
|
||||
if (onClientsChange) {
|
||||
onClientsChange(clients);
|
||||
}
|
||||
}, [clients, onClientsChange]);
|
||||
|
||||
// Sync with initialClients prop changes (wizard mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'wizard') {
|
||||
setClients(initialClients);
|
||||
}
|
||||
}, [initialClients, mode]);
|
||||
|
||||
const fetchClients = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -172,7 +159,9 @@ export function DownloadClientManagement({
|
||||
await fetchClients(); // Refresh list
|
||||
} else {
|
||||
// Local removal for wizard mode
|
||||
setClients(clients.filter(c => c.id !== deleteConfirm.clientId));
|
||||
const updated = clients.filter(c => c.id !== deleteConfirm.clientId);
|
||||
setClients(updated);
|
||||
onClientsChange?.(updated);
|
||||
}
|
||||
|
||||
setDeleteConfirm({ isOpen: false });
|
||||
@@ -219,15 +208,18 @@ export function DownloadClientManagement({
|
||||
}
|
||||
} else {
|
||||
// Local update for wizard mode
|
||||
let updated: DownloadClient[];
|
||||
if (modalState.mode === 'add') {
|
||||
const newClient = {
|
||||
...clientData,
|
||||
id: `temp-${Date.now()}`, // Temporary ID for wizard mode
|
||||
};
|
||||
setClients([...clients, newClient]);
|
||||
updated = [...clients, newClient];
|
||||
} else {
|
||||
setClients(clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c)));
|
||||
updated = clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c));
|
||||
}
|
||||
setClients(updated);
|
||||
onClientsChange?.(updated);
|
||||
}
|
||||
|
||||
setModalState({ isOpen: false, mode: 'add' });
|
||||
@@ -261,7 +253,7 @@ export function DownloadClientManagement({
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Add Download Client
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{/* qBittorrent Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
@@ -324,6 +316,37 @@ export function DownloadClientManagement({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deluge Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Deluge
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Torrent downloads
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium">
|
||||
Torrent
|
||||
</span>
|
||||
</div>
|
||||
{hasTorrentClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleAddClient('deluge')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
Add Deluge
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SABnzbd Card */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
import { DownloadClientType, getClientDisplayName, CLIENT_PROTOCOL_MAP } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
interface DownloadClientModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -31,6 +31,7 @@ interface DownloadClientModalProps {
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
postImportCategory?: string;
|
||||
};
|
||||
onSave: (client: any) => Promise<void>;
|
||||
apiMode: 'wizard' | 'settings';
|
||||
@@ -62,6 +63,9 @@ export function DownloadClientModal({
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [category, setCategory] = useState('readmeabook');
|
||||
const [customPath, setCustomPath] = useState('');
|
||||
const [postImportCategory, setPostImportCategory] = useState('');
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
const [fetchingCategories, setFetchingCategories] = useState(false);
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -85,6 +89,7 @@ export function DownloadClientModal({
|
||||
setLocalPath(initialClient.localPath || '');
|
||||
setCategory(initialClient.category || 'readmeabook');
|
||||
setCustomPath(initialClient.customPath || '');
|
||||
setPostImportCategory(initialClient.postImportCategory || '');
|
||||
} else {
|
||||
// Add mode defaults
|
||||
setName(typeName);
|
||||
@@ -98,9 +103,12 @@ export function DownloadClientModal({
|
||||
setLocalPath('');
|
||||
setCategory('readmeabook');
|
||||
setCustomPath('');
|
||||
setPostImportCategory('');
|
||||
}
|
||||
setTestResult(null);
|
||||
setErrors({});
|
||||
setAvailableCategories([]);
|
||||
setFetchingCategories(false);
|
||||
}
|
||||
}, [isOpen, mode, initialClient, type]);
|
||||
|
||||
@@ -137,6 +145,50 @@ export function DownloadClientModal({
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setFetchingCategories(true);
|
||||
try {
|
||||
const isPasswordMasked = password === '********';
|
||||
const categoryData = {
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
username: username || undefined,
|
||||
password: isPasswordMasked ? undefined : password,
|
||||
...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
|
||||
disableSSLVerify,
|
||||
remotePathMappingEnabled,
|
||||
remotePath: remotePathMappingEnabled ? remotePath : undefined,
|
||||
localPath: remotePathMappingEnabled ? localPath : undefined,
|
||||
};
|
||||
|
||||
const endpoint = apiMode === 'wizard'
|
||||
? '/api/setup/download-client-categories'
|
||||
: '/api/admin/settings/download-clients/categories';
|
||||
|
||||
const response = apiMode === 'wizard'
|
||||
? await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(categoryData),
|
||||
})
|
||||
: await fetchWithAuth(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(categoryData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok && data.success) {
|
||||
setAvailableCategories(data.categories || []);
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — categories are optional
|
||||
} finally {
|
||||
setFetchingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!validate()) {
|
||||
return;
|
||||
@@ -187,6 +239,11 @@ export function DownloadClientModal({
|
||||
// Handle both endpoint response formats (settings returns message, wizard returns version)
|
||||
const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful');
|
||||
setTestResult({ success: true, message });
|
||||
|
||||
// Fetch categories for torrent clients after successful connection
|
||||
if (type && CLIENT_PROTOCOL_MAP[type] === 'torrent') {
|
||||
fetchCategories();
|
||||
}
|
||||
} else {
|
||||
setTestResult({ success: false, message: data.error || 'Connection test failed' });
|
||||
}
|
||||
@@ -221,7 +278,7 @@ export function DownloadClientModal({
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
username: type !== 'sabnzbd' ? username : undefined,
|
||||
username: type !== 'sabnzbd' && type !== 'deluge' ? username : undefined,
|
||||
password: password === '********' ? undefined : password, // Don't send masked password on edit
|
||||
enabled,
|
||||
disableSSLVerify,
|
||||
@@ -229,7 +286,8 @@ export function DownloadClientModal({
|
||||
remotePath: remotePathMappingEnabled ? remotePath : undefined,
|
||||
localPath: remotePathMappingEnabled ? localPath : undefined,
|
||||
category,
|
||||
customPath: sanitizedCustomPath || undefined,
|
||||
customPath: sanitizedCustomPath,
|
||||
postImportCategory,
|
||||
};
|
||||
|
||||
if (mode === 'edit' && initialClient) {
|
||||
@@ -280,7 +338,7 @@ export function DownloadClientModal({
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
|
||||
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'deluge' ? 'http://localhost:8112' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
|
||||
error={errors.url}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -288,8 +346,8 @@ export function DownloadClientModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Username (qBittorrent and Transmission) */}
|
||||
{type !== 'sabnzbd' && (
|
||||
{/* Username (qBittorrent, Transmission, NZBGet — not SABnzbd or Deluge) */}
|
||||
{type !== 'sabnzbd' && type !== 'deluge' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
@@ -325,6 +383,11 @@ export function DownloadClientModal({
|
||||
Configured in NZBGet under Settings → Security → ControlPassword
|
||||
</p>
|
||||
)}
|
||||
{type === 'deluge' && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Web UI password configured in Deluge under Preferences → Interface
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSL Verification */}
|
||||
@@ -384,6 +447,37 @@ export function DownloadClientModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Post-Import Category (torrent clients only) */}
|
||||
{type && CLIENT_PROTOCOL_MAP[type] === 'torrent' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Post-Import Category
|
||||
</label>
|
||||
{(type === 'qbittorrent' || type === 'deluge') && availableCategories.length > 0 ? (
|
||||
<select
|
||||
value={postImportCategory}
|
||||
onChange={(e) => setPostImportCategory(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">None (keep original)</option>
|
||||
{availableCategories.map((cat) => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
value={postImportCategory}
|
||||
onChange={(e) => setPostImportCategory(e.target.value)}
|
||||
placeholder="e.g. completed"
|
||||
disabled={fetchingCategories}
|
||||
/>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
After import, change the download's category/label in the client. Leave empty to skip.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-start mb-3">
|
||||
|
||||
@@ -63,17 +63,14 @@ export function IndexerManagement({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Sync with parent when configuredIndexers changes
|
||||
// In settings mode, the parent fetches indexers asynchronously and passes them
|
||||
// as initialIndexers after mount. This effect picks up that late-arriving data.
|
||||
// Wizard mode doesn't need this — it initializes correctly via useState above.
|
||||
useEffect(() => {
|
||||
if (onIndexersChange) {
|
||||
onIndexersChange(configuredIndexers);
|
||||
if (mode === 'settings') {
|
||||
setConfiguredIndexers(initialIndexers);
|
||||
}
|
||||
}, [configuredIndexers, onIndexersChange]);
|
||||
|
||||
// Sync with initialIndexers prop changes
|
||||
useEffect(() => {
|
||||
setConfiguredIndexers(initialIndexers);
|
||||
}, [initialIndexers]);
|
||||
}, [initialIndexers, mode]);
|
||||
|
||||
const fetchIndexers = async () => {
|
||||
setLoading(true);
|
||||
@@ -149,17 +146,16 @@ export function IndexerManagement({
|
||||
};
|
||||
|
||||
const handleSave = (config: SavedIndexerConfig) => {
|
||||
let updated: SavedIndexerConfig[];
|
||||
if (modalState.mode === 'add') {
|
||||
// Add new indexer
|
||||
setConfiguredIndexers([...configuredIndexers, config]);
|
||||
updated = [...configuredIndexers, config];
|
||||
} else {
|
||||
// Update existing indexer
|
||||
setConfiguredIndexers(
|
||||
configuredIndexers.map((idx) =>
|
||||
idx.id === config.id ? config : idx
|
||||
)
|
||||
updated = configuredIndexers.map((idx) =>
|
||||
idx.id === config.id ? config : idx
|
||||
);
|
||||
}
|
||||
setConfiguredIndexers(updated);
|
||||
onIndexersChange?.(updated);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
@@ -175,9 +171,9 @@ export function IndexerManagement({
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteModalState.indexerId) {
|
||||
setConfiguredIndexers(
|
||||
configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId)
|
||||
);
|
||||
const updated = configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId);
|
||||
setConfiguredIndexers(updated);
|
||||
onIndexersChange?.(updated);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ export function AudiobookCard({
|
||||
requestStatus={audiobook.requestStatus}
|
||||
isAvailable={audiobook.isAvailable}
|
||||
requestedByUsername={audiobook.requestedByUsername}
|
||||
hasReportedIssue={audiobook.hasReportedIssue}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
|
||||
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
||||
|
||||
interface AudiobookDetailsModalProps {
|
||||
asin: string;
|
||||
@@ -27,6 +29,7 @@ interface AudiobookDetailsModalProps {
|
||||
isAvailable?: boolean;
|
||||
requestedByUsername?: string | null;
|
||||
hideRequestActions?: boolean;
|
||||
hasReportedIssue?: boolean;
|
||||
}
|
||||
|
||||
// Status helper
|
||||
@@ -65,10 +68,11 @@ export function AudiobookDetailsModal({
|
||||
isAvailable = false,
|
||||
requestedByUsername = null,
|
||||
hideRequestActions = false,
|
||||
hasReportedIssue = false,
|
||||
}: AudiobookDetailsModalProps) {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
|
||||
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
|
||||
const { createRequest, isLoading: isRequesting } = useCreateRequest();
|
||||
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
|
||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||
@@ -79,6 +83,7 @@ export function AudiobookDetailsModal({
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||
const [showReportIssue, setShowReportIssue] = useState(false);
|
||||
const [asinCopied, setAsinCopied] = useState(false);
|
||||
|
||||
const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername);
|
||||
@@ -282,13 +287,44 @@ export function AudiobookDetailsModal({
|
||||
{audiobook.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-300">
|
||||
{audiobook.author}
|
||||
{audiobook.authorAsin ? (
|
||||
<Link
|
||||
href={`/authors/${audiobook.authorAsin}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
{audiobook.author}
|
||||
</Link>
|
||||
) : (
|
||||
audiobook.author
|
||||
)}
|
||||
</p>
|
||||
{audiobook.narrator && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Narrated by {audiobook.narrator}
|
||||
</p>
|
||||
)}
|
||||
{audiobook.series && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{audiobook.seriesAsin ? (
|
||||
<Link
|
||||
href={`/series/${audiobook.seriesAsin}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Status Badge */}
|
||||
{status.type !== 'none' && (
|
||||
@@ -316,6 +352,33 @@ export function AudiobookDetailsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issue Reported Badge */}
|
||||
{isAvailable && hasReportedIssue && (
|
||||
<div className="mt-2 inline-flex">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
|
||||
</svg>
|
||||
Issue Reported
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report Issue Button - inline with metadata, not in action bar */}
|
||||
{isAvailable && !hasReportedIssue && user && (
|
||||
<div className="mt-2 inline-flex">
|
||||
<button
|
||||
onClick={() => setShowReportIssue(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
|
||||
</svg>
|
||||
Report Issue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Metadata */}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center sm:justify-start gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{audiobook.durationMinutes && (
|
||||
@@ -387,7 +450,7 @@ export function AudiobookDetailsModal({
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Source</p>
|
||||
<a
|
||||
href={`https://www.audible.com/pd/${asin}`}
|
||||
href={`${audibleBaseUrl}/pd/${asin}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-orange-600 dark:text-orange-400 hover:underline"
|
||||
@@ -526,6 +589,7 @@ export function AudiobookDetailsModal({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -594,6 +658,22 @@ export function AudiobookDetailsModal({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Report Issue Modal */}
|
||||
{showReportIssue && audiobook && (
|
||||
<ReportIssueModal
|
||||
isOpen={showReportIssue}
|
||||
onClose={() => setShowReportIssue(false)}
|
||||
onSuccess={() => {
|
||||
setShowReportIssue(false);
|
||||
showNotification('Issue reported!');
|
||||
}}
|
||||
asin={asin}
|
||||
bookTitle={audiobook.title}
|
||||
bookAuthor={audiobook.author}
|
||||
coverArtUrl={audiobook.coverArtUrl}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Component: Report Issue Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Sub-modal for reporting problems with available audiobooks.
|
||||
* Rendered via portal at z-[60] to layer above AudiobookDetailsModal.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useReportIssue } from '@/lib/hooks/useReportedIssues';
|
||||
|
||||
interface ReportIssueModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
asin: string;
|
||||
bookTitle: string;
|
||||
bookAuthor: string;
|
||||
coverArtUrl?: string;
|
||||
}
|
||||
|
||||
export function ReportIssueModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
asin,
|
||||
bookTitle,
|
||||
bookAuthor,
|
||||
coverArtUrl,
|
||||
}: ReportIssueModalProps) {
|
||||
const { reportIssue, isLoading } = useReportIssue();
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const maxChars = 250;
|
||||
const canSubmit = reason.trim().length > 0 && reason.length <= maxChars && !isLoading;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
await reportIssue(asin, reason.trim(), {
|
||||
title: bookTitle,
|
||||
author: bookAuthor,
|
||||
coverArtUrl,
|
||||
});
|
||||
setReason('');
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to report issue');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 dark:bg-black/60 backdrop-blur-sm animate-in fade-in duration-150"
|
||||
onClick={() => !isLoading && onClose()}
|
||||
>
|
||||
<div
|
||||
className="mx-5 w-full max-w-sm bg-white dark:bg-gray-800 rounded-2xl shadow-2xl shadow-black/20 overflow-hidden animate-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-500/10 dark:bg-orange-400/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[15px] font-semibold text-gray-900 dark:text-white">
|
||||
Report Issue
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 truncate">
|
||||
{bookTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason Textarea */}
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => {
|
||||
setReason(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder="Describe the problem (e.g., corrupted audio, wrong book, missing chapters...)"
|
||||
rows={3}
|
||||
maxLength={maxChars}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3.5 py-2.5 bg-gray-50 dark:bg-white/[0.06] rounded-xl border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 resize-none focus:outline-none focus:border-orange-500/40 focus:ring-1 focus:ring-orange-500/20 transition-all disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="min-h-[1.25rem]">
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs tabular-nums ${reason.length > maxChars ? 'text-red-500' : 'text-gray-400 dark:text-gray-500'}`}>
|
||||
{reason.length}/{maxChars}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex border-t border-gray-200/80 dark:border-gray-700/50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-4 py-3 text-[15px] font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors disabled:opacity-40 border-r border-gray-200/80 dark:border-gray-700/50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="flex-1 px-4 py-3 text-[15px] font-semibold text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-500/10 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-orange-300 dark:border-orange-600 border-t-orange-600 dark:border-t-orange-400 rounded-full animate-spin" />
|
||||
Submitting...
|
||||
</span>
|
||||
) : (
|
||||
'Submit Report'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Component: Author Card
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Premium circular portrait design - distinguishes authors from audiobook covers.
|
||||
* Hover effects and typography match the AudiobookCard aesthetic.
|
||||
* Clicking navigates to the author's detail page.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Author } from '@/lib/hooks/useAuthors';
|
||||
|
||||
interface AuthorCardProps {
|
||||
author: Author;
|
||||
}
|
||||
|
||||
export function AuthorCard({ author }: AuthorCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/authors/${author.asin}`}
|
||||
className="group outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 rounded-2xl"
|
||||
aria-label={`View details for ${author.name}`}
|
||||
>
|
||||
{/* Circular Portrait Container */}
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="
|
||||
relative overflow-hidden rounded-full
|
||||
w-full aspect-square
|
||||
shadow-lg shadow-black/20 dark:shadow-black/40
|
||||
group-hover:shadow-xl group-hover:shadow-black/25 dark:group-hover:shadow-black/50
|
||||
transform group-hover:scale-[1.04] group-hover:-translate-y-1
|
||||
transition-all duration-300 ease-out
|
||||
"
|
||||
>
|
||||
{author.image ? (
|
||||
<Image
|
||||
src={author.image}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
|
||||
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle hover overlay */}
|
||||
<div className="
|
||||
absolute inset-0 rounded-full
|
||||
bg-black/0 group-hover:bg-black/10
|
||||
transition-colors duration-300
|
||||
" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Info */}
|
||||
<div className="mt-3 px-1 text-center">
|
||||
<h3 className="font-semibold text-[15px] leading-snug text-gray-900 dark:text-gray-100 line-clamp-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
|
||||
{author.name}
|
||||
</h3>
|
||||
|
||||
{/* Genre Pills */}
|
||||
{author.genres.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap justify-center gap-1">
|
||||
{author.genres.map(genre => (
|
||||
<span
|
||||
key={genre}
|
||||
className="inline-block px-2 py-0.5 text-[11px] font-medium rounded-full bg-gray-100 dark:bg-gray-700/60 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Component: Author Detail Card
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Hero section for the author detail page with circular portrait,
|
||||
* name, collapsible biography, and genre pills.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { AuthorDetail } from '@/lib/hooks/useAuthors';
|
||||
|
||||
interface AuthorDetailCardProps {
|
||||
author: AuthorDetail;
|
||||
}
|
||||
|
||||
export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasLongDescription = (author.description?.length || 0) > 300;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
|
||||
{/* Circular Portrait */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40">
|
||||
{author.image ? (
|
||||
<Image
|
||||
src={author.image}
|
||||
alt={author.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
|
||||
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Info */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{author.name}
|
||||
</h1>
|
||||
|
||||
{/* Genre Pills */}
|
||||
{author.genres.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
|
||||
{author.genres.map(genre => (
|
||||
<span
|
||||
key={genre}
|
||||
className="inline-block px-3 py-1 text-xs font-medium rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{author.audibleUrl && (
|
||||
<a
|
||||
href={author.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{author.description && (
|
||||
<div className="mt-4">
|
||||
<p
|
||||
className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line ${
|
||||
!expanded && hasLongDescription ? 'line-clamp-4' : ''
|
||||
}`}
|
||||
>
|
||||
{author.description}
|
||||
</p>
|
||||
{hasLongDescription && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="mt-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
{expanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthorDetailSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
|
||||
{/* Portrait skeleton */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
|
||||
<div className="w-full h-full rounded-full relative overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info skeleton */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left space-y-4">
|
||||
<div className="h-9 bg-gray-200 dark:bg-gray-700 rounded-lg w-64 mx-auto sm:mx-0" />
|
||||
<div className="flex gap-2 justify-center sm:justify-start">
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-6 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Component: Author Grid
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Premium grid layout for author cards with loading skeletons and empty state.
|
||||
* Mirrors AudiobookGrid patterns with author-appropriate column counts.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AuthorCard } from './AuthorCard';
|
||||
import { Author } from '@/lib/hooks/useAuthors';
|
||||
|
||||
interface AuthorGridProps {
|
||||
authors: Author[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
cardSize?: number;
|
||||
}
|
||||
|
||||
// Authors use wider spacing since circular portraits need room to breathe.
|
||||
// Slightly fewer columns than AudiobookGrid at each breakpoint since circles
|
||||
// are visually wider than 2:3 portrait covers.
|
||||
function getGridClasses(size: number): string {
|
||||
const sizeMap: Record<number, string> = {
|
||||
1: 'grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
|
||||
2: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
|
||||
3: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
|
||||
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6',
|
||||
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
|
||||
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
7: 'grid-cols-2 md:grid-cols-3',
|
||||
8: 'grid-cols-2',
|
||||
9: 'grid-cols-1',
|
||||
};
|
||||
return sizeMap[size] || sizeMap[5];
|
||||
}
|
||||
|
||||
export function AuthorGrid({
|
||||
authors,
|
||||
isLoading = false,
|
||||
emptyMessage = 'No authors found',
|
||||
cardSize = 5,
|
||||
}: AuthorGridProps) {
|
||||
const gridClasses = getGridClasses(cardSize);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<AuthorSkeletonCard key={i} index={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authors.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
|
||||
<svg className="w-10 h-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
|
||||
{authors.map(author => (
|
||||
<AuthorCard key={author.asin} author={author} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorSkeletonCard({ index = 0 }: { index?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{/* Circular portrait skeleton */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative overflow-hidden rounded-full w-full aspect-square bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text skeleton */}
|
||||
<div className="mt-3 px-1 flex flex-col items-center space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-4/5" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Component: Similar Authors Row
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Horizontal scrollable carousel of similar author cards.
|
||||
* Desktop: left/right nav arrows. Mobile: drag-to-scroll.
|
||||
* Each card navigates to the author's detail page.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { SimilarAuthor } from '@/lib/hooks/useAuthors';
|
||||
|
||||
interface SimilarAuthorsRowProps {
|
||||
authors: SimilarAuthor[];
|
||||
currentAuthorName?: string;
|
||||
}
|
||||
|
||||
export function SimilarAuthorsRow({ authors, currentAuthorName }: SimilarAuthorsRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('scroll', checkScroll, { passive: true });
|
||||
const observer = new ResizeObserver(checkScroll);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkScroll);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [checkScroll, authors]);
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const scrollAmount = el.clientWidth * 0.7;
|
||||
el.scrollBy({
|
||||
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
if (authors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Similar Authors
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({authors.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
{/* Left arrow */}
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable row */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-4 sm:gap-5 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{authors.map(author => (
|
||||
<Link
|
||||
key={author.asin}
|
||||
href={`/authors/${author.asin}${currentAuthorName ? `?from=${encodeURIComponent(currentAuthorName)}` : ''}`}
|
||||
className="flex-shrink-0 w-24 sm:w-28 group/card outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-xl"
|
||||
>
|
||||
{/* Circular portrait */}
|
||||
<div className="relative w-24 h-24 sm:w-28 sm:h-28 rounded-full overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300">
|
||||
{author.image ? (
|
||||
<Image
|
||||
src={author.image}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="112px"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-blue-400 dark:text-blue-300">
|
||||
{author.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<p className="mt-2 text-xs sm:text-sm font-medium text-center text-gray-700 dark:text-gray-300 line-clamp-2 group-hover/card:text-indigo-600 dark:group-hover/card:text-indigo-400 transition-colors">
|
||||
{author.name}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right arrow */}
|
||||
{canScrollRight && (
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fade edges */}
|
||||
{canScrollLeft && (
|
||||
<div className="hidden md:block absolute left-0 top-0 bottom-2 w-8 bg-gradient-to-r from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
|
||||
)}
|
||||
{canScrollRight && (
|
||||
<div className="hidden md:block absolute right-0 top-0 bottom-2 w-8 bg-gradient-to-l from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimilarAuthorsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
<div className="h-7 w-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-4 sm:gap-5 overflow-hidden">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-24 sm:w-28" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className="w-24 h-24 sm:w-28 sm:h-28 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
<div className="mt-2 h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5 mx-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { VersionBadge } from '@/components/ui/VersionBadge';
|
||||
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
|
||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
|
||||
export function Header() {
|
||||
@@ -20,6 +21,7 @@ export function Header() {
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
const [showBookDate, setShowBookDate] = useState(false);
|
||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||
const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
|
||||
|
||||
// Check if user can change password (local users only)
|
||||
@@ -90,6 +92,15 @@ export function Header() {
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowAddGoodreadsModal(true);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Add Goodreads Shelf
|
||||
</button>
|
||||
{canChangePassword && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -149,6 +160,18 @@ export function Header() {
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
<Link
|
||||
href="/authors"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
<Link
|
||||
href="/series"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Series
|
||||
</Link>
|
||||
{showBookDate && (
|
||||
<Link
|
||||
href="/bookdate"
|
||||
@@ -253,6 +276,20 @@ export function Header() {
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
<Link
|
||||
href="/authors"
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
<Link
|
||||
href="/series"
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
Series
|
||||
</Link>
|
||||
{showBookDate && (
|
||||
<Link
|
||||
href="/bookdate"
|
||||
@@ -297,6 +334,12 @@ export function Header() {
|
||||
isOpen={showChangePasswordModal}
|
||||
onClose={() => setShowChangePasswordModal(false)}
|
||||
/>
|
||||
|
||||
{/* Add Goodreads Shelf Modal */}
|
||||
<AddGoodreadsShelfModal
|
||||
isOpen={showAddGoodreadsModal}
|
||||
onClose={() => setShowAddGoodreadsModal(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Component: Goodreads Shelves Section (Profile Page)
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function GoodreadsShelvesSection() {
|
||||
const { shelves, isLoading } = useGoodreadsShelves();
|
||||
const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf();
|
||||
const { squareCovers } = usePreferences();
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (shelfId: string) => {
|
||||
try {
|
||||
await deleteShelf(shelfId);
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10">
|
||||
<svg className="w-[18px] h-[18px] text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
|
||||
Goodreads Shelves
|
||||
</h2>
|
||||
{!isLoading && shelves.length > 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<ShelfCardSkeleton squareCovers={squareCovers} />
|
||||
) : shelves.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{shelves.map((shelf) => (
|
||||
<ShelfCard
|
||||
key={shelf.id}
|
||||
shelf={shelf}
|
||||
squareCovers={squareCovers}
|
||||
isDeleting={isDeleting && confirmDeleteId === shelf.id}
|
||||
isConfirmingDelete={confirmDeleteId === shelf.id}
|
||||
onDelete={() => handleDelete(shelf.id)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState onAdd={() => setShowAddModal(true)} />
|
||||
)}
|
||||
|
||||
<AddGoodreadsShelfModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
/>
|
||||
|
||||
{/* Audiobook Detail Modal (read-only) */}
|
||||
{selectedAsin && (
|
||||
<AudiobookDetailsModal
|
||||
asin={selectedAsin}
|
||||
isOpen={true}
|
||||
onClose={() => setSelectedAsin(null)}
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Empty State ─── */
|
||||
|
||||
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700/40 p-10 sm:p-14 text-center">
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center mb-5 ring-1 ring-amber-200/50 dark:ring-amber-500/10">
|
||||
<svg className="w-7 h-7 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-200 mb-1.5">
|
||||
Connect your reading list
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed">
|
||||
Link a Goodreads shelf and we'll automatically request the audiobook for every book you add.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Your First Shelf
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Loading Skeleton ─── */
|
||||
|
||||
function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7">
|
||||
<div className="mb-5">
|
||||
<div className="h-[18px] w-52 bg-gray-100 dark:bg-gray-700/50 rounded-lg animate-pulse mb-2.5" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-[22px] w-16 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
|
||||
<div className="h-3.5 w-24 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Shelf Card ─── */
|
||||
|
||||
interface ShelfCardProps {
|
||||
shelf: GoodreadsShelf;
|
||||
squareCovers: boolean;
|
||||
isDeleting: boolean;
|
||||
isConfirmingDelete: boolean;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onBookClick: (asin: string) => void;
|
||||
}
|
||||
|
||||
function ShelfCard({
|
||||
shelf,
|
||||
squareCovers,
|
||||
isDeleting,
|
||||
isConfirmingDelete,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onBookClick,
|
||||
}: ShelfCardProps) {
|
||||
const displayBooks = shelf.books.slice(0, 6);
|
||||
const hasCovers = displayBooks.length > 0;
|
||||
const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length);
|
||||
const isSyncing = !shelf.lastSyncAt;
|
||||
|
||||
return (
|
||||
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
|
||||
{/* Top: Shelf info + actions */}
|
||||
<div className={cn('flex items-start justify-between', (hasCovers || isSyncing) && 'mb-5')}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
|
||||
{shelf.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{shelf.bookCount != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
Syncing…
|
||||
</>
|
||||
) : shelf.lastSyncAt ? (
|
||||
<>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
Synced {formatRelativeTime(shelf.lastSyncAt)}
|
||||
</>
|
||||
) : (
|
||||
'Pending sync'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete action */}
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
{isConfirmingDelete ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-3 py-1.5 text-xs font-semibold text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Removing\u2026' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1.5 text-xs font-medium text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Remove shelf"
|
||||
>
|
||||
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Stacked book covers */}
|
||||
{hasCovers ? (
|
||||
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} />
|
||||
) : isSyncing ? (
|
||||
<div className="flex items-end">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Stacked Cover Display ─── */
|
||||
|
||||
function CoverStack({
|
||||
books,
|
||||
remainingCount,
|
||||
squareCovers,
|
||||
onBookClick,
|
||||
}: {
|
||||
books: ShelfBook[];
|
||||
remainingCount: number;
|
||||
squareCovers: boolean;
|
||||
onBookClick: (asin: string) => void;
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const coverSize = squareCovers
|
||||
? 'w-[80px] aspect-square'
|
||||
: 'w-[72px] aspect-[2/3]';
|
||||
|
||||
return (
|
||||
<div className="flex items-end">
|
||||
{books.map((book, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'relative rounded-xl overflow-hidden shadow-md flex-shrink-0',
|
||||
'ring-2 ring-white dark:ring-gray-800',
|
||||
'transition-all duration-300 ease-out',
|
||||
hoveredIndex === i && 'scale-[1.18] shadow-xl',
|
||||
coverSize,
|
||||
book.asin ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
style={{
|
||||
marginLeft: i > 0 ? '-16px' : 0,
|
||||
zIndex: hoveredIndex === i ? 50 : books.length - i,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(i)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => book.asin && onBookClick(book.asin)}
|
||||
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
|
||||
>
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl flex items-center justify-center bg-gray-50 dark:bg-gray-700/30 border border-gray-100 dark:border-gray-700/40 flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
coverSize
|
||||
)}
|
||||
style={{ marginLeft: '-16px', zIndex: 0 }}
|
||||
>
|
||||
<span className="text-sm font-semibold text-gray-400 dark:text-gray-500 tabular-nums">
|
||||
+{remainingCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useInteractiveSearchEbookByAsin,
|
||||
useSelectEbookByAsin,
|
||||
} from '@/lib/hooks/useRequests';
|
||||
import { useReplaceWithTorrent } from '@/lib/hooks/useReportedIssues';
|
||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
|
||||
interface InteractiveTorrentSearchModalProps {
|
||||
@@ -36,6 +37,7 @@ interface InteractiveTorrentSearchModalProps {
|
||||
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
|
||||
onSuccess?: () => void;
|
||||
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
|
||||
replaceIssueId?: string; // Optional - when set, confirm handler calls replace endpoint instead
|
||||
}
|
||||
|
||||
// Format relative time from publish date
|
||||
@@ -87,11 +89,15 @@ export function InteractiveTorrentSearchModal({
|
||||
fullAudiobook,
|
||||
onSuccess,
|
||||
searchMode = 'audiobook',
|
||||
replaceIssueId,
|
||||
}: InteractiveTorrentSearchModalProps) {
|
||||
// Hooks for existing audiobook request flow
|
||||
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
|
||||
const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
|
||||
|
||||
// Hook for reported issue replacement flow
|
||||
const { replaceWithTorrent, isLoading: isReplacing, error: replaceError } = useReplaceWithTorrent();
|
||||
|
||||
// Hooks for new audiobook flow
|
||||
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
|
||||
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
|
||||
@@ -124,14 +130,18 @@ export function InteractiveTorrentSearchModal({
|
||||
const isSearching = isEbookMode
|
||||
? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks)
|
||||
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
|
||||
const isDownloading = isEbookMode
|
||||
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
|
||||
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
|
||||
const error = isEbookMode
|
||||
? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError))
|
||||
: (hasRequestId
|
||||
? (searchByRequestError || selectTorrentError)
|
||||
: (searchByAudiobookError || requestWithTorrentError));
|
||||
const isDownloading = replaceIssueId
|
||||
? isReplacing
|
||||
: isEbookMode
|
||||
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
|
||||
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
|
||||
const error = replaceIssueId
|
||||
? (replaceError || (hasRequestId ? searchByRequestError : searchByAudiobookError))
|
||||
: isEbookMode
|
||||
? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError))
|
||||
: (hasRequestId
|
||||
? (searchByRequestError || selectTorrentError)
|
||||
: (searchByAudiobookError || requestWithTorrentError));
|
||||
|
||||
// Mount tracking for portal
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
@@ -188,7 +198,7 @@ export function InteractiveTorrentSearchModal({
|
||||
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
||||
data = await searchByRequestId(requestId, customTitle);
|
||||
} else {
|
||||
const audiobookAsin = fullAudiobook?.asin;
|
||||
const audiobookAsin = fullAudiobook?.asin || asin;
|
||||
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
|
||||
}
|
||||
setResults(data || []);
|
||||
@@ -208,7 +218,10 @@ export function InteractiveTorrentSearchModal({
|
||||
const handleConfirmDownload = async () => {
|
||||
if (!confirmTorrent) return;
|
||||
try {
|
||||
if (isEbookMode) {
|
||||
if (replaceIssueId) {
|
||||
// Reported issue replacement flow
|
||||
await replaceWithTorrent(replaceIssueId, confirmTorrent);
|
||||
} else if (isEbookMode) {
|
||||
if (useAsinMode && asin) {
|
||||
await selectEbookByAsin(asin, confirmTorrent);
|
||||
} else if (requestId) {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Component: Series Card
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Premium "Cover First" design - metadata integrated into the cover overlay.
|
||||
* Rating badge top-left, book count top-right, tags in bottom gradient overlay.
|
||||
* Only the title lives below the cover, ensuring consistent row heights in the grid.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { SeriesSummary } from '@/lib/hooks/useSeries';
|
||||
|
||||
interface SeriesCardProps {
|
||||
series: SeriesSummary;
|
||||
squareCovers?: boolean;
|
||||
}
|
||||
|
||||
export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
|
||||
const visibleTags = series.tags.slice(0, 2);
|
||||
const hasTags = visibleTags.length > 0;
|
||||
const hasRating = series.rating != null && series.rating > 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/series/${series.asin}`}
|
||||
className="group outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent rounded-2xl block"
|
||||
aria-label={`View ${series.title} series`}
|
||||
>
|
||||
{/* Cover Container — The Hero */}
|
||||
<div
|
||||
className={`
|
||||
relative overflow-hidden rounded-xl
|
||||
w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'}
|
||||
shadow-lg shadow-black/20 dark:shadow-black/40
|
||||
group-hover:shadow-xl group-hover:shadow-black/30 dark:group-hover:shadow-black/55
|
||||
transform group-hover:scale-[1.02] group-hover:-translate-y-0.5
|
||||
transition-all duration-300 ease-out
|
||||
`}
|
||||
>
|
||||
{/* Cover Art or Fallback */}
|
||||
{series.coverArtUrl ? (
|
||||
<Image
|
||||
src={series.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-600 to-teal-800 dark:from-emerald-700 dark:to-teal-900 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-1/3 h-1/3 text-white/40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-row badges — Rating (left) + Book count (right) */}
|
||||
{/* Rating Badge — top-left, matches AudiobookCard pattern exactly */}
|
||||
{hasRating && (
|
||||
<div className="
|
||||
absolute top-2.5 left-2.5
|
||||
flex items-center gap-1 px-2 py-1
|
||||
rounded-lg bg-black/50 backdrop-blur-md
|
||||
text-white text-xs font-medium
|
||||
transition-opacity duration-300 group-hover:opacity-0
|
||||
">
|
||||
<svg className="w-3.5 h-3.5 text-amber-400 shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span>{series.rating!.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Book count badge — top-right */}
|
||||
{series.bookCount > 0 && (
|
||||
<div className="
|
||||
absolute top-2.5 right-2.5
|
||||
px-2 py-1
|
||||
text-[11px] font-bold rounded-lg
|
||||
bg-black/50 backdrop-blur-md
|
||||
text-white
|
||||
transition-opacity duration-300 group-hover:opacity-0
|
||||
">
|
||||
{series.bookCount} {series.bookCount === 1 ? 'Book' : 'Books'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom gradient overlay — always present, deepens on hover */}
|
||||
<div className={`
|
||||
absolute inset-x-0 bottom-0
|
||||
transition-all duration-300
|
||||
${hasTags
|
||||
? 'h-20 bg-gradient-to-t from-black/75 via-black/30 to-transparent group-hover:h-24 group-hover:from-black/85'
|
||||
: 'h-10 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100'
|
||||
}
|
||||
`} />
|
||||
|
||||
{/* Tag pills — pinned to bottom of cover, inside gradient */}
|
||||
{hasTags && (
|
||||
<div className="
|
||||
absolute inset-x-0 bottom-0
|
||||
flex items-end gap-1.5 p-2.5
|
||||
pointer-events-none
|
||||
">
|
||||
{visibleTags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="
|
||||
inline-block px-2.5 py-0.5
|
||||
text-[10px] font-medium
|
||||
rounded-full
|
||||
bg-black/30 backdrop-blur-md
|
||||
text-white/90
|
||||
ring-1 ring-white/15
|
||||
transition-opacity duration-300
|
||||
"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Below-cover: title only — fixed, predictable height across all cards */}
|
||||
<div className="mt-2.5 px-0.5">
|
||||
<h3 className="
|
||||
font-semibold text-[14px] leading-snug
|
||||
text-gray-900 dark:text-gray-100
|
||||
line-clamp-2
|
||||
group-hover:text-emerald-600 dark:group-hover:text-emerald-400
|
||||
transition-colors duration-200
|
||||
">
|
||||
{series.title}
|
||||
</h3>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Component: Series Detail Card
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Hero section for the series detail page with rectangular cover image,
|
||||
* title, book count, rating, collapsible description, and tag pills.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
||||
|
||||
interface SeriesDetailCardProps {
|
||||
series: SeriesDetail;
|
||||
squareCovers?: boolean;
|
||||
}
|
||||
|
||||
export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasLongDescription = (series.description?.length || 0) > 300;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
|
||||
{/* Rectangular Cover */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`relative w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40`}>
|
||||
{series.books[0]?.coverArtUrl ? (
|
||||
<Image
|
||||
src={series.books[0].coverArtUrl}
|
||||
alt={series.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
|
||||
<svg className="w-1/3 h-1/3 text-emerald-400 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Series Info */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{series.title}
|
||||
</h1>
|
||||
|
||||
{/* Meta row: book count + rating */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||
{series.bookCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-300">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
{series.bookCount} Book{series.bookCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{series.rating != null && series.rating > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg className="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
{series.rating.toFixed(1)}
|
||||
{series.ratingCount != null && series.ratingCount > 0 && (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
({series.ratingCount.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag Pills */}
|
||||
{series.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
|
||||
{series.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-block px-3 py-1 text-xs font-medium rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{series.audibleUrl && (
|
||||
<a
|
||||
href={series.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{series.description && (
|
||||
<div className="mt-4">
|
||||
<p
|
||||
className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line ${
|
||||
!expanded && hasLongDescription ? 'line-clamp-4' : ''
|
||||
}`}
|
||||
>
|
||||
{series.description}
|
||||
</p>
|
||||
{hasLongDescription && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="mt-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
{expanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeriesDetailSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
|
||||
{/* Cover skeleton */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden`}>
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info skeleton */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left space-y-4">
|
||||
<div className="h-9 bg-gray-200 dark:bg-gray-700 rounded-lg w-64 mx-auto sm:mx-0" />
|
||||
<div className="flex gap-2 justify-center sm:justify-start">
|
||||
<div className="h-7 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-7 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center sm:justify-start">
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-6 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Component: Series Grid
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Grid layout for series cards with loading skeletons and empty state.
|
||||
* Uses the same responsive column system as AudiobookGrid since
|
||||
* series cards use rectangular (2:3) aspect ratios like book covers.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { SeriesCard } from './SeriesCard';
|
||||
import { SeriesSummary } from '@/lib/hooks/useSeries';
|
||||
|
||||
interface SeriesGridProps {
|
||||
series: SeriesSummary[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
cardSize?: number;
|
||||
squareCovers?: boolean;
|
||||
}
|
||||
|
||||
function getGridClasses(size: number): string {
|
||||
const sizeMap: Record<number, string> = {
|
||||
1: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
|
||||
2: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
|
||||
3: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
|
||||
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
|
||||
7: 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
8: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
|
||||
9: 'grid-cols-1 sm:grid-cols-2',
|
||||
};
|
||||
return sizeMap[size] || sizeMap[5];
|
||||
}
|
||||
|
||||
export function SeriesGrid({
|
||||
series,
|
||||
isLoading = false,
|
||||
emptyMessage = 'No series found',
|
||||
cardSize = 5,
|
||||
squareCovers = false,
|
||||
}: SeriesGridProps) {
|
||||
const gridClasses = getGridClasses(cardSize);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<SeriesSkeletonCard key={i} index={i} squareCovers={squareCovers} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (series.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
|
||||
<svg className="w-10 h-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
|
||||
{series.map(s => (
|
||||
<SeriesCard key={s.asin} series={s} squareCovers={squareCovers} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesSkeletonCard({ index = 0, squareCovers = false }: { index?: number; squareCovers?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{/* Rectangular cover skeleton */}
|
||||
<div className={`relative overflow-hidden rounded-xl w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800`}>
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Text skeleton */}
|
||||
<div className="mt-3 px-1 flex flex-col items-center space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-4/5" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Component: Similar Series Row
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Horizontal scrollable carousel of similar series cards.
|
||||
* Desktop: left/right nav arrows. Mobile: drag-to-scroll.
|
||||
* Each card navigates to the series detail page.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { SimilarSeries } from '@/lib/hooks/useSeries';
|
||||
|
||||
interface SimilarSeriesRowProps {
|
||||
series: SimilarSeries[];
|
||||
currentSeriesTitle?: string;
|
||||
squareCovers?: boolean;
|
||||
}
|
||||
|
||||
export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = false }: SimilarSeriesRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('scroll', checkScroll, { passive: true });
|
||||
const observer = new ResizeObserver(checkScroll);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkScroll);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [checkScroll, series]);
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const scrollAmount = el.clientWidth * 0.7;
|
||||
el.scrollBy({
|
||||
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
if (series.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Similar Series
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({series.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
{/* Left arrow */}
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable row */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-4 sm:gap-5 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{series.map(s => (
|
||||
<Link
|
||||
key={s.asin}
|
||||
href={`/series/${s.asin}${currentSeriesTitle ? `?from=${encodeURIComponent(currentSeriesTitle)}` : ''}`}
|
||||
className="flex-shrink-0 w-20 sm:w-24 group/card outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 rounded-xl"
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className={`relative w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300`}>
|
||||
{s.coverArtUrl ? (
|
||||
<Image
|
||||
src={s.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-emerald-400 dark:text-emerald-300">
|
||||
{s.title.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className="mt-2 text-xs sm:text-sm font-medium text-center text-gray-700 dark:text-gray-300 line-clamp-2 group-hover/card:text-emerald-600 dark:group-hover/card:text-emerald-400 transition-colors">
|
||||
{s.title}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right arrow */}
|
||||
{canScrollRight && (
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fade edges */}
|
||||
{canScrollLeft && (
|
||||
<div className="hidden md:block absolute left-0 top-0 bottom-2 w-8 bg-gradient-to-r from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
|
||||
)}
|
||||
{canScrollRight && (
|
||||
<div className="hidden md:block absolute right-0 top-0 bottom-2 w-8 bg-gradient-to-l from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimilarSeriesSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
|
||||
return (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
<div className="h-7 w-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-4 sm:gap-5 overflow-hidden">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-20 sm:w-24" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className={`w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden`}>
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
<div className="mt-2 h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5 mx-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Component: Add Goodreads Shelf Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
|
||||
interface AddGoodreadsShelfModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||
|
||||
export function AddGoodreadsShelfModal({ isOpen, onClose }: AddGoodreadsShelfModalProps) {
|
||||
const [rssUrl, setRssUrl] = useState('');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const { addShelf, isLoading, error } = useAddGoodreadsShelf();
|
||||
|
||||
const validateUrl = (url: string): boolean => {
|
||||
if (!url.trim()) {
|
||||
setValidationError('RSS URL is required');
|
||||
return false;
|
||||
}
|
||||
if (!GOODREADS_RSS_PATTERN.test(url)) {
|
||||
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
|
||||
return false;
|
||||
}
|
||||
setValidationError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateUrl(rssUrl)) return;
|
||||
|
||||
try {
|
||||
const shelf = await addShelf(rssUrl);
|
||||
setSuccess(true);
|
||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||
setRssUrl('');
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRssUrl('');
|
||||
setValidationError('');
|
||||
setSuccess(false);
|
||||
setSuccessMessage('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Goodreads Shelf" size="sm">
|
||||
<div className="space-y-5">
|
||||
{/* Visual header */}
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.5 8.257a4.5 4.5 0 007.244 1.242" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success alert */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<Input
|
||||
type="url"
|
||||
label="Goodreads RSS URL"
|
||||
value={rssUrl}
|
||||
onChange={(e) => {
|
||||
setRssUrl(e.target.value);
|
||||
if (validationError) setValidationError('');
|
||||
}}
|
||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||
error={validationError}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
|
||||
Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={isLoading}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Add Shelf
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
@@ -22,6 +22,24 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [allowWeakPassword, setAllowWeakPassword] = useState(false);
|
||||
|
||||
// Fetch password policy when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const fetchPolicy = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/providers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAllowWeakPassword(data.allowWeakPassword === true);
|
||||
}
|
||||
} catch {
|
||||
// Default to strict validation on error
|
||||
}
|
||||
};
|
||||
fetchPolicy();
|
||||
}, [isOpen]);
|
||||
|
||||
// Validation errors for individual fields
|
||||
const [errors, setErrors] = useState({
|
||||
@@ -47,7 +65,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
|
||||
if (!newPassword) {
|
||||
newErrors.newPassword = 'New password is required';
|
||||
isValid = false;
|
||||
} else if (newPassword.length < 8) {
|
||||
} else if (!allowWeakPassword && newPassword.length < 8) {
|
||||
newErrors.newPassword = 'Password must be at least 8 characters';
|
||||
isValid = false;
|
||||
} else if (newPassword === currentPassword) {
|
||||
@@ -211,7 +229,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
|
||||
}}
|
||||
placeholder="Enter your new password"
|
||||
autoComplete="new-password"
|
||||
helperText="Must be at least 8 characters"
|
||||
helperText={allowWeakPassword ? undefined : 'Must be at least 8 characters'}
|
||||
error={errors.newPassword}
|
||||
disabled={loading || success}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Component: Centralized Language Configuration
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Single source of truth for all language-specific configuration.
|
||||
* To add a new language:
|
||||
* 1. Add code to SupportedLanguage union
|
||||
* 2. Add full LanguageConfig entry in LANGUAGE_CONFIGS
|
||||
* 3. Map regions in REGION_LANGUAGE_MAP
|
||||
* 4. Add region to AUDIBLE_REGIONS in audible.ts with language: 'xx'
|
||||
*/
|
||||
|
||||
import type { AudibleRegion } from '../types/audible';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SupportedLanguage = 'en' | 'de' | 'es';
|
||||
|
||||
export interface ScrapingConfig {
|
||||
/** Audible locale query-param value (e.g. 'english', 'deutsch') */
|
||||
audibleLocaleParam: string;
|
||||
/** Author label prefixes to strip (e.g. ['By:', 'Written by:']) */
|
||||
authorPrefixes: string[];
|
||||
/** Narrator label prefixes to strip */
|
||||
narratorPrefixes: string[];
|
||||
/** Length / duration labels used in Cheerio :contains() selectors */
|
||||
lengthLabels: string[];
|
||||
/** Language field labels */
|
||||
languageLabels: string[];
|
||||
/** Release date field labels */
|
||||
releaseDateLabels: string[];
|
||||
/** Series label prefixes used to find series links in search results */
|
||||
seriesLabels: string[];
|
||||
/** Accepted language values for filtering (lowercase) */
|
||||
acceptedLanguageValues: string[];
|
||||
/** Regex patterns that match hour portions in runtime strings */
|
||||
runtimeHourPatterns: RegExp[];
|
||||
/** Regex patterns that match minute portions in runtime strings */
|
||||
runtimeMinutePatterns: RegExp[];
|
||||
/** Regex patterns for extracting numeric rating */
|
||||
ratingPatterns: RegExp[];
|
||||
/** Regex patterns for extracting release date text */
|
||||
releaseDatePatterns: RegExp[];
|
||||
/** Promotional / non-description text patterns to exclude */
|
||||
descriptionExcludePatterns: RegExp[];
|
||||
/** Duration detection pattern for generic element scanning */
|
||||
durationDetectionPattern: RegExp;
|
||||
/** Rating text selector pattern (e.g. 'out of 5 stars') */
|
||||
ratingTextSelector: string;
|
||||
}
|
||||
|
||||
export interface LanguageConfig {
|
||||
code: SupportedLanguage;
|
||||
/** Anna's Archive language filter code */
|
||||
annasArchiveLang: string;
|
||||
/** EPUB language code */
|
||||
epubCode: string;
|
||||
/** Stop words for ranking algorithm (filtered from match scoring) */
|
||||
stopWords: string[];
|
||||
/** Character replacements applied before NFD normalization in ranking (e.g. ß→ss) */
|
||||
characterReplacements: Record<string, string>;
|
||||
/** All scraping-related config */
|
||||
scraping: ScrapingConfig;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language Configurations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ENGLISH_CONFIG: LanguageConfig = {
|
||||
code: 'en',
|
||||
annasArchiveLang: 'en',
|
||||
epubCode: 'en',
|
||||
stopWords: ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for'],
|
||||
characterReplacements: {},
|
||||
scraping: {
|
||||
audibleLocaleParam: 'english',
|
||||
authorPrefixes: ['By:', 'Written by:'],
|
||||
narratorPrefixes: ['Narrated by:'],
|
||||
lengthLabels: ['Length:'],
|
||||
languageLabels: ['Language:'],
|
||||
releaseDateLabels: ['Release date:'],
|
||||
seriesLabels: ['Series:'],
|
||||
acceptedLanguageValues: ['english'],
|
||||
runtimeHourPatterns: [/(\d+)\s*hrs?/i, /(\d+)\s*hours?/i],
|
||||
runtimeMinutePatterns: [/(\d+)\s*mins?/i, /(\d+)\s*minutes?/i],
|
||||
ratingPatterns: [/(\d+\.?\d*)\s*out of/i],
|
||||
releaseDatePatterns: [/Release date:\s*(.+)/i],
|
||||
descriptionExcludePatterns: [
|
||||
/\$\d+\.\d+/,
|
||||
/cancel anytime/i,
|
||||
/free trial/i,
|
||||
/membership/i,
|
||||
/subscribe/i,
|
||||
/offer.*ends/i,
|
||||
/^\s*by\s+[\w\s,]+$/i,
|
||||
],
|
||||
durationDetectionPattern: /\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i,
|
||||
ratingTextSelector: 'out of 5 stars',
|
||||
},
|
||||
};
|
||||
|
||||
const GERMAN_CONFIG: LanguageConfig = {
|
||||
code: 'de',
|
||||
annasArchiveLang: 'de',
|
||||
epubCode: 'de',
|
||||
stopWords: ['der', 'die', 'das', 'ein', 'eine', 'und', 'von', 'zu', 'den', 'dem', 'des'],
|
||||
characterReplacements: { '\u00df': 'ss' },
|
||||
scraping: {
|
||||
audibleLocaleParam: 'deutsch',
|
||||
authorPrefixes: ['Von:', 'Geschrieben von:', 'Autor:'],
|
||||
narratorPrefixes: ['Gesprochen von:', 'Sprecher:'],
|
||||
lengthLabels: ['Spieldauer:', 'Dauer:', 'L\u00e4nge:'],
|
||||
languageLabels: ['Sprache:'],
|
||||
releaseDateLabels: ['Erscheinungsdatum:'],
|
||||
seriesLabels: ['Serie:', 'Reihe:'],
|
||||
acceptedLanguageValues: ['deutsch', 'german'],
|
||||
runtimeHourPatterns: [/(\d+)\s*Std\.?/i, /(\d+)\s*Stunden?/i],
|
||||
runtimeMinutePatterns: [/(\d+)\s*Min\.?/i, /(\d+)\s*Minuten?/i],
|
||||
ratingPatterns: [/(\d+[.,]?\d*)\s*von\s*5/i],
|
||||
releaseDatePatterns: [/Erscheinungsdatum:\s*(.+)/i],
|
||||
descriptionExcludePatterns: [
|
||||
/\$\d+\.\d+/,
|
||||
/\d+,\d+\s*\u20ac/,
|
||||
/jederzeit k\u00fcndbar/i,
|
||||
/kostenlos testen/i,
|
||||
/Mitgliedschaft/i,
|
||||
/abonnieren/i,
|
||||
/Angebot.*endet/i,
|
||||
/^\s*von\s+[\w\s,]+$/i,
|
||||
],
|
||||
durationDetectionPattern: /\d+\s*(Std|Stunden?|h)\s*\.?\s*\d*\s*(Min|Minuten?|m)?/i,
|
||||
ratingTextSelector: 'von 5 Sternen',
|
||||
},
|
||||
};
|
||||
|
||||
const SPANISH_CONFIG: LanguageConfig = {
|
||||
code: 'es',
|
||||
annasArchiveLang: 'es',
|
||||
epubCode: 'es',
|
||||
stopWords: ['el', 'la', 'los', 'las', 'un', 'una', 'de', 'del', 'en', 'y', 'por'],
|
||||
characterReplacements: {},
|
||||
scraping: {
|
||||
audibleLocaleParam: 'espa\u00f1ol',
|
||||
authorPrefixes: ['De:', 'Escrito por:', 'Autor:'],
|
||||
narratorPrefixes: ['Narrado por:'],
|
||||
lengthLabels: ['Duraci\u00f3n:'],
|
||||
languageLabels: ['Idioma:'],
|
||||
releaseDateLabels: ['Fecha de lanzamiento:'],
|
||||
seriesLabels: ['Serie:'],
|
||||
acceptedLanguageValues: ['espa\u00f1ol', 'spanish'],
|
||||
runtimeHourPatterns: [/(\d+)\s*h\b/i, /(\d+)\s*horas?/i],
|
||||
runtimeMinutePatterns: [/(\d+)\s*min/i, /(\d+)\s*minutos?/i],
|
||||
ratingPatterns: [/(\d+[.,]?\d*)\s*de\s*5/i],
|
||||
releaseDatePatterns: [/Fecha de lanzamiento:\s*(.+)/i],
|
||||
descriptionExcludePatterns: [
|
||||
/\$\d+\.\d+/,
|
||||
/\d+,\d+\s*\u20ac/,
|
||||
/cancela cuando quieras/i,
|
||||
/prueba gratis/i,
|
||||
/suscripci\u00f3n/i,
|
||||
/suscr\u00edbete/i,
|
||||
/oferta.*termina/i,
|
||||
/^\s*de\s+[\w\s,]+$/i,
|
||||
],
|
||||
durationDetectionPattern: /\d+\s*(h|horas?)\s*\d*\s*(min|minutos?)?/i,
|
||||
ratingTextSelector: 'de 5 estrellas',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lookup Maps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LANGUAGE_CONFIGS: Record<SupportedLanguage, LanguageConfig> = {
|
||||
en: ENGLISH_CONFIG,
|
||||
de: GERMAN_CONFIG,
|
||||
es: SPANISH_CONFIG,
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps Audible region codes to language codes.
|
||||
* All English-speaking regions map to 'en'.
|
||||
*/
|
||||
export const REGION_LANGUAGE_MAP: Record<AudibleRegion, SupportedLanguage> = {
|
||||
us: 'en',
|
||||
ca: 'en',
|
||||
uk: 'en',
|
||||
au: 'en',
|
||||
in: 'en',
|
||||
de: 'de',
|
||||
es: 'es',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the full language configuration for an Audible region.
|
||||
*/
|
||||
export function getLanguageForRegion(region: AudibleRegion): LanguageConfig {
|
||||
const langCode = REGION_LANGUAGE_MAP[region];
|
||||
return LANGUAGE_CONFIGS[langCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip any matching prefixes from text (case-insensitive).
|
||||
* Returns the text with the first matching prefix removed, trimmed.
|
||||
*
|
||||
* Example: stripPrefixes('By: Author Name', ['By:', 'Written by:']) => 'Author Name'
|
||||
*/
|
||||
export function stripPrefixes(text: string, prefixes: string[]): string {
|
||||
const trimmed = text.trim();
|
||||
for (const prefix of prefixes) {
|
||||
if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return trimmed.slice(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Cheerio selector that matches any of the given labels using :contains().
|
||||
* Returns a comma-separated selector string.
|
||||
*
|
||||
* Example: buildContainsSelector('span', ['Length:', 'Dauer:'])
|
||||
* => 'span:contains("Length:"), span:contains("Dauer:")'
|
||||
*/
|
||||
export function buildContainsSelector(element: string, labels: string[]): string {
|
||||
return labels.map(label => `${element}:contains("${label}")`).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a value from text by trying multiple label patterns.
|
||||
* Returns the captured group from the first matching pattern, or null.
|
||||
*/
|
||||
export function extractByPatterns(text: string, patterns: RegExp[]): string | null {
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a language value matches the accepted values for a language config.
|
||||
* Comparison is case-insensitive.
|
||||
*/
|
||||
export function isAcceptedLanguage(languageValue: string, config: LanguageConfig): boolean {
|
||||
const normalized = languageValue.toLowerCase().trim();
|
||||
return config.scraping.acceptedLanguageValues.includes(normalized);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Component: Notification Event Constants
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*
|
||||
* Single source of truth for all notification event types and metadata.
|
||||
* Add new events here — all providers, API schemas, and UI labels derive from this.
|
||||
*/
|
||||
|
||||
export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
|
||||
export type NotificationPriority = 'normal' | 'high';
|
||||
|
||||
/**
|
||||
* Central registry of notification events.
|
||||
*
|
||||
* Each entry defines:
|
||||
* - `label`: Human-readable name shown in the UI
|
||||
* - `title`: Default title used in notification messages
|
||||
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available")
|
||||
* - `emoji`: Emoji prefix for notification titles
|
||||
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
|
||||
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
|
||||
*/
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
request_pending_approval: {
|
||||
label: 'Request Pending Approval',
|
||||
title: 'New Request Pending Approval',
|
||||
emoji: '\u{1F4EC}',
|
||||
severity: 'info' as const,
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
request_approved: {
|
||||
label: 'Request Approved',
|
||||
title: 'Request Approved',
|
||||
emoji: '\u2705',
|
||||
severity: 'success' as const,
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
request_available: {
|
||||
label: 'Request Available',
|
||||
title: 'Request Available',
|
||||
titleByRequestType: {
|
||||
audiobook: 'Audiobook Available',
|
||||
ebook: 'Ebook Available',
|
||||
} as Record<string, string>,
|
||||
emoji: '\u{1F389}',
|
||||
severity: 'success' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
request_error: {
|
||||
label: 'Request Error',
|
||||
title: 'Request Error',
|
||||
emoji: '\u274C',
|
||||
severity: 'error' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
issue_reported: {
|
||||
label: 'Issue Reported',
|
||||
title: 'Issue Reported',
|
||||
emoji: '\u{1F6A9}',
|
||||
severity: 'warning' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Union type of all valid notification event keys */
|
||||
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
||||
|
||||
/** Ordered array of all notification event keys (for Zod schemas, iteration) */
|
||||
export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [NotificationEvent, ...NotificationEvent[]];
|
||||
|
||||
/** Metadata shape for a single notification event */
|
||||
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
|
||||
|
||||
/** Helper: get event metadata by key */
|
||||
export function getEventMeta(event: NotificationEvent) {
|
||||
return NOTIFICATION_EVENTS[event];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: get the resolved notification title for an event.
|
||||
* If the event has a `titleByRequestType` map and a matching requestType is provided,
|
||||
* returns the type-specific title. Otherwise falls back to the default `title`.
|
||||
*/
|
||||
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
|
||||
const meta = NOTIFICATION_EVENTS[event];
|
||||
if (requestType && 'titleByRequestType' in meta) {
|
||||
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
|
||||
if (typeTitle) return typeTitle;
|
||||
}
|
||||
return meta.title;
|
||||
}
|
||||
|
||||
/** Helper: get the human-readable label for an event */
|
||||
export function getEventLabel(event: NotificationEvent): string {
|
||||
return NOTIFICATION_EVENTS[event].label;
|
||||
}
|
||||
|
||||
/** Record mapping all event keys to their labels (for UI dropdowns, etc.) */
|
||||
export const EVENT_LABELS: Record<NotificationEvent, string> = Object.fromEntries(
|
||||
Object.entries(NOTIFICATION_EVENTS).map(([key, meta]) => [key, meta.label])
|
||||
) as Record<NotificationEvent, string>;
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
import { PrismaClient } from '@/generated/prisma/client';
|
||||
|
||||
/**
|
||||
* Append connection pool parameters to DATABASE_URL if not already present.
|
||||
* - connection_limit=20: up from default 9, fits 22 max workers + API routes
|
||||
* - pool_timeout=30: up from default 10s, gives queued requests time
|
||||
*/
|
||||
function getPooledDatabaseUrl(): string {
|
||||
const baseUrl = process.env.DATABASE_URL || '';
|
||||
if (!baseUrl) return baseUrl;
|
||||
|
||||
const separator = baseUrl.includes('?') ? '&' : '?';
|
||||
const params: string[] = [];
|
||||
|
||||
if (!baseUrl.includes('connection_limit')) {
|
||||
params.push('connection_limit=20');
|
||||
}
|
||||
if (!baseUrl.includes('pool_timeout')) {
|
||||
params.push('pool_timeout=30');
|
||||
}
|
||||
|
||||
if (params.length === 0) return baseUrl;
|
||||
return `${baseUrl}${separator}${params.join('&')}`;
|
||||
}
|
||||
|
||||
// Prevent multiple instances of Prisma Client in development
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
@@ -14,6 +37,11 @@ export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
datasources: {
|
||||
db: {
|
||||
url: getPooledDatabaseUrl(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user