12 Commits

24 changed files with 1307 additions and 118 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.env
*.log
data/*
!data/branding/
!data/branding/**
frontend/node_modules/
frontend/.next/
backend/__pycache__/
**/__pycache__/
**/*.pyc

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
.env .env
.venv/ .venv/
data/ data/
!data/branding/
!data/branding/**
backend/__pycache__/ backend/__pycache__/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc

View File

@@ -5,10 +5,11 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
COPY requirements.txt . COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY backend/app ./app
COPY data/branding /app/data/branding
EXPOSE 8000 EXPOSE 8000

View File

@@ -101,5 +101,10 @@ class Settings(BaseSettings):
default=None, validation_alias=AliasChoices("QBIT_PASSWORD", "QBITTORRENT_PASSWORD") default=None, validation_alias=AliasChoices("QBIT_PASSWORD", "QBITTORRENT_PASSWORD")
) )
discord_webhook_url: Optional[str] = Field(
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
settings = Settings() settings = Settings()

View File

@@ -500,7 +500,7 @@ def get_cached_requests(
since_iso: Optional[str] = None, since_iso: Optional[str] = None,
) -> list[Dict[str, Any]]: ) -> list[Dict[str, Any]]:
query = """ query = """
SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, payload_json
FROM requests_cache FROM requests_cache
""" """
params: list[Any] = [] params: list[Any] = []
@@ -525,14 +525,33 @@ def get_cached_requests(
) )
results: list[Dict[str, Any]] = [] results: list[Dict[str, Any]] = []
for row in rows: for row in rows:
title = row[4]
year = row[5]
if (not title or not year) and row[8]:
try:
payload = json.loads(row[8])
if isinstance(payload, dict):
media = payload.get("media") or {}
if not title:
title = (
(media.get("title") if isinstance(media, dict) else None)
or (media.get("name") if isinstance(media, dict) else None)
or payload.get("title")
or payload.get("name")
)
if not year:
year = media.get("year") if isinstance(media, dict) else None
year = year or payload.get("year")
except json.JSONDecodeError:
pass
results.append( results.append(
{ {
"request_id": row[0], "request_id": row[0],
"media_id": row[1], "media_id": row[1],
"media_type": row[2], "media_type": row[2],
"status": row[3], "status": row[3],
"title": row[4], "title": title,
"year": row[5], "year": year,
"requested_by": row[6], "requested_by": row[6],
"created_at": row[7], "created_at": row[7],
} }

View File

@@ -17,6 +17,7 @@ from .routers.admin import router as admin_router
from .routers.images import router as images_router from .routers.images import router as images_router
from .routers.branding import router as branding_router from .routers.branding import router as branding_router
from .routers.status import router as status_router from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging from .logging_config import configure_logging
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
@@ -54,3 +55,4 @@ app.include_router(admin_router)
app.include_router(images_router) app.include_router(images_router)
app.include_router(branding_router) app.include_router(branding_router)
app.include_router(status_router) app.include_router(status_router)
app.include_router(feedback_router)

View File

@@ -4,7 +4,7 @@ from typing import Any, Dict
from fastapi import APIRouter, HTTPException, UploadFile, File from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from PIL import Image from PIL import Image, ImageDraw, ImageFont
router = APIRouter(prefix="/branding", tags=["branding"]) router = APIRouter(prefix="/branding", tags=["branding"])
@@ -23,8 +23,52 @@ def _resize_image(image: Image.Image, max_size: int = 300) -> Image.Image:
return image return image
def _load_font(size: int) -> ImageFont.ImageFont:
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
for path in candidates:
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
def _ensure_default_branding() -> None:
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
return
_ensure_branding_dir()
if not os.path.exists(_LOGO_PATH):
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
draw = ImageDraw.Draw(image)
font = _load_font(160)
text = "M"
box = draw.textbbox((0, 0), text, font=font)
text_w = box[2] - box[0]
text_h = box[3] - box[1]
draw.text(
((300 - text_w) / 2, (300 - text_h) / 2 - 6),
text,
font=font,
fill=(255, 255, 255, 255),
)
image.save(_LOGO_PATH, format="PNG")
if not os.path.exists(_FAVICON_PATH):
favicon = Image.open(_LOGO_PATH).copy()
favicon.thumbnail((64, 64))
try:
favicon.save(_FAVICON_PATH, format="ICO", sizes=[(32, 32), (64, 64)])
except OSError:
favicon.save(_FAVICON_PATH, format="ICO")
@router.get("/logo.png") @router.get("/logo.png")
async def branding_logo() -> FileResponse: async def branding_logo() -> FileResponse:
if not os.path.exists(_LOGO_PATH):
_ensure_default_branding()
if not os.path.exists(_LOGO_PATH): if not os.path.exists(_LOGO_PATH):
raise HTTPException(status_code=404, detail="Logo not found") raise HTTPException(status_code=404, detail="Logo not found")
headers = {"Cache-Control": "public, max-age=300"} headers = {"Cache-Control": "public, max-age=300"}
@@ -33,6 +77,8 @@ async def branding_logo() -> FileResponse:
@router.get("/favicon.ico") @router.get("/favicon.ico")
async def branding_favicon() -> FileResponse: async def branding_favicon() -> FileResponse:
if not os.path.exists(_FAVICON_PATH):
_ensure_default_branding()
if not os.path.exists(_FAVICON_PATH): if not os.path.exists(_FAVICON_PATH):
raise HTTPException(status_code=404, detail="Favicon not found") raise HTTPException(status_code=404, detail="Favicon not found")
headers = {"Cache-Control": "public, max-age=300"} headers = {"Cache-Control": "public, max-age=300"}

View File

@@ -0,0 +1,38 @@
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
@router.post("")
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
webhook_url = runtime.discord_webhook_url
if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured")
feedback_type = str(payload.get("type") or "").strip().lower()
if feedback_type not in {"bug", "feature"}:
raise HTTPException(status_code=400, detail="Invalid feedback type")
message = str(payload.get("message") or "").strip()
if not message:
raise HTTPException(status_code=400, detail="Message is required")
if len(message) > 2000:
raise HTTPException(status_code=400, detail="Message is too long")
username = user.get("username") or "unknown"
content = f"**{feedback_type.title()}** from **{username}**\n{message}"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(webhook_url, json={"content": content})
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"status": "ok"}

View File

@@ -9,6 +9,7 @@ from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellyfin import JellyfinClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
@@ -1101,6 +1102,38 @@ async def recent_requests(
allow_remote = mode == "always_js" allow_remote = mode == "always_js"
allow_title_hydrate = mode == "prefer_cache" allow_title_hydrate = mode == "prefer_cache"
allow_artwork_hydrate = allow_remote or allow_title_hydrate allow_artwork_hydrate = allow_remote or allow_title_hydrate
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
async def _jellyfin_available(
title_value: Optional[str], year_value: Optional[int], media_type_value: Optional[str]
) -> bool:
if not jellyfin.configured() or not title_value:
return False
cache_key = f"{media_type_value or ''}:{title_value.lower()}:{year_value or ''}"
cached_value = jellyfin_cache.get(cache_key)
if cached_value is not None:
return cached_value
types = ["Movie"] if media_type_value == "movie" else ["Series"]
try:
search = await jellyfin.search_items(title_value, types)
except Exception:
jellyfin_cache[cache_key] = False
return False
if isinstance(search, dict):
items = search.get("Items") or search.get("items") or []
for item in items:
if not isinstance(item, dict):
continue
name = item.get("Name") or item.get("title")
year = item.get("ProductionYear") or item.get("Year")
if name and name.strip().lower() == title_value.strip().lower():
if year_value and year and int(year) != int(year_value):
continue
jellyfin_cache[cache_key] = True
return True
jellyfin_cache[cache_key] = False
return False
results = [] results = []
for row in rows: for row in rows:
status = row.get("status") status = row.get("status")
@@ -1192,6 +1225,11 @@ async def recent_requests(
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=json.dumps(details, ensure_ascii=True), payload_json=json.dumps(details, ensure_ascii=True),
) )
status_label = _status_label(status)
if status_label == "Working on it":
is_available = await _jellyfin_available(title, year, row.get("media_type"))
if is_available:
status_label = "Available"
results.append( results.append(
{ {
"id": row.get("request_id"), "id": row.get("request_id"),
@@ -1199,7 +1237,7 @@ async def recent_requests(
"year": year, "year": year,
"type": row.get("media_type"), "type": row.get("media_type"),
"status": status, "status": status,
"statusLabel": _status_label(status), "statusLabel": status_label,
"mediaId": row.get("media_id"), "mediaId": row.get("media_id"),
"artwork": { "artwork": {
"poster_url": _artwork_url(poster_path, "w185", cache_mode), "poster_url": _artwork_url(poster_path, "w185", cache_mode),

BIN
data/branding/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
data/branding/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

19
docker-compose.hub.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
backend:
image: rephl3xnz/magent-backend:latest
env_file:
- ./.env
ports:
- "8000:8000"
volumes:
- ./data:/app/data
frontend:
image: rephl3xnz/magent-frontend:latest
environment:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend

View File

@@ -1,8 +1,8 @@
services: services:
backend: backend:
build: build:
context: ./backend context: .
dockerfile: Dockerfile dockerfile: backend/Dockerfile
env_file: env_file:
- ./.env - ./.env
ports: ports:

View File

@@ -8,6 +8,7 @@ COPY package.json ./
RUN npm install RUN npm install
COPY app ./app COPY app ./app
COPY public ./public
COPY next-env.d.ts ./next-env.d.ts COPY next-env.d.ts ./next-env.d.ts
COPY next.config.js ./next.config.js COPY next.config.js ./next.config.js
COPY tsconfig.json ./tsconfig.json COPY tsconfig.json ./tsconfig.json
@@ -22,6 +23,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js ./next.config.js COPY --from=builder /app/next.config.js ./next.config.js

View File

@@ -29,9 +29,46 @@ const SECTION_LABELS: Record<string, string> = {
qbittorrent: 'qBittorrent', qbittorrent: 'qBittorrent',
log: 'Activity log', log: 'Activity log',
requests: 'Request syncing', requests: 'Request syncing',
invites: 'Invites',
password: 'Password rules',
captcha: 'Captcha',
smtp: 'Email (SMTP)',
notify: 'Notifications',
expiry: 'Account expiry',
} }
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr']) const BOOL_SETTINGS = new Set([
'jellyfin_sync_to_arr',
'invites_enabled',
'invites_require_captcha',
'signup_allow_referrals',
'password_require_upper',
'password_require_lower',
'password_require_number',
'password_require_symbol',
'password_reset_enabled',
'smtp_tls',
'smtp_starttls',
'notify_email_enabled',
'notify_discord_enabled',
'notify_telegram_enabled',
'notify_matrix_enabled',
'notify_pushover_enabled',
'notify_pushbullet_enabled',
'notify_gotify_enabled',
'notify_ntfy_enabled',
'jellyseerr_sync_users',
])
const NUMBER_SETTINGS = new Set([
'invite_default_profile_id',
'referral_default_uses',
'password_min_length',
'smtp_port',
'expiry_default_days',
'expiry_warning_days',
'expiry_check_interval_minutes',
'jellyseerr_sync_interval_minutes',
])
const SECTION_DESCRIPTIONS: Record<string, string> = { const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.', jellyseerr: 'Connect the request system where users submit content.',
@@ -44,6 +81,12 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
qbittorrent: 'Downloader connection settings.', qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.', requests: 'Sync and refresh cadence for requests.',
log: 'Activity log for troubleshooting.', log: 'Activity log for troubleshooting.',
invites: 'Invite-only sign-ups and default rules.',
password: 'Set global password rules and local reset settings.',
captcha: 'Choose and configure captcha providers.',
smtp: 'Email delivery settings for password resets and notices.',
notify: 'Where system messages should be sent.',
expiry: 'Handle account expiry and automated actions.',
} }
const SETTINGS_SECTION_MAP: Record<string, string | null> = { const SETTINGS_SECTION_MAP: Record<string, string | null> = {
@@ -55,6 +98,12 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
prowlarr: 'prowlarr', prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent', qbittorrent: 'qbittorrent',
requests: 'requests', requests: 'requests',
invites: 'invites',
password: 'password',
captcha: 'captcha',
smtp: 'smtp',
notifications: 'notify',
expiry: 'expiry',
cache: null, cache: null,
logs: 'log', logs: 'log',
maintenance: null, maintenance: null,
@@ -78,6 +127,59 @@ const labelFromKey = (key: string) =>
.replace('jellyfin public url', 'Jellyfin public URL') .replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr') .replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode') .replace('artwork cache mode', 'Artwork cache mode')
.replace('invites enabled', 'Allow invite sign-ups')
.replace('invites require captcha', 'Require captcha for invite sign-ups')
.replace('invite default profile id', 'Default invite profile')
.replace('signup allow referrals', 'Allow users to create referral invites')
.replace('referral default uses', 'Default referral invite uses')
.replace('password min length', 'Minimum password length')
.replace('password require upper', 'Require uppercase letters')
.replace('password require lower', 'Require lowercase letters')
.replace('password require number', 'Require numbers')
.replace('password require symbol', 'Require symbols')
.replace('password reset enabled', 'Allow password reset emails')
.replace('captcha provider', 'Captcha provider')
.replace('hcaptcha site key', 'hCaptcha site key')
.replace('hcaptcha secret key', 'hCaptcha secret key')
.replace('recaptcha site key', 'reCAPTCHA site key')
.replace('recaptcha secret key', 'reCAPTCHA secret key')
.replace('turnstile site key', 'Turnstile site key')
.replace('turnstile secret key', 'Turnstile secret key')
.replace('smtp host', 'SMTP host')
.replace('smtp port', 'SMTP port')
.replace('smtp user', 'SMTP username')
.replace('smtp password', 'SMTP password')
.replace('smtp from', 'SMTP from address')
.replace('smtp tls', 'Use TLS (SMTPS)')
.replace('smtp starttls', 'Use STARTTLS')
.replace('notify email enabled', 'Send emails')
.replace('notify discord enabled', 'Send Discord alerts')
.replace('notify telegram enabled', 'Send Telegram alerts')
.replace('notify matrix enabled', 'Send Matrix alerts')
.replace('notify pushover enabled', 'Send Pushover alerts')
.replace('notify pushbullet enabled', 'Send Pushbullet alerts')
.replace('notify gotify enabled', 'Send Gotify alerts')
.replace('notify ntfy enabled', 'Send ntfy alerts')
.replace('telegram bot token', 'Telegram bot token')
.replace('telegram chat id', 'Telegram chat ID')
.replace('matrix homeserver', 'Matrix homeserver')
.replace('matrix user', 'Matrix username')
.replace('matrix password', 'Matrix password')
.replace('matrix access token', 'Matrix access token')
.replace('matrix room id', 'Matrix room ID')
.replace('pushover token', 'Pushover app token')
.replace('pushover user key', 'Pushover user key')
.replace('pushbullet token', 'Pushbullet access token')
.replace('gotify url', 'Gotify URL')
.replace('gotify token', 'Gotify token')
.replace('ntfy url', 'ntfy URL')
.replace('ntfy topic', 'ntfy topic')
.replace('expiry default days', 'Default account expiry (days)')
.replace('expiry default action', 'Expiry action')
.replace('expiry warning days', 'Warn users this many days before expiry')
.replace('expiry check interval minutes', 'Expiry check interval (minutes)')
.replace('jellyseerr sync users', 'Sync Jellyseerr users into Magent')
.replace('jellyseerr sync interval minutes', 'Jellyseerr sync interval (minutes)')
type SettingsPageProps = { type SettingsPageProps = {
section: string section: string
@@ -106,6 +208,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null) const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null) const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false) const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [invites, setInvites] = useState<any[]>([])
const [inviteProfiles, setInviteProfiles] = useState<any[]>([])
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteForm, setInviteForm] = useState({
profile_id: '',
max_uses: '1',
require_captcha: false,
allow_referrals: false,
})
const [profileForm, setProfileForm] = useState({
name: '',
description: '',
max_uses: '',
require_captcha: false,
allow_referrals: false,
referral_uses: '',
user_expiry_action: 'disable',
})
const [inviteExpiry, setInviteExpiry] = useState({ unit: 'days', value: '7' })
const [profileExpiry, setProfileExpiry] = useState({ unit: 'days', value: '' })
const [profileUserExpiry, setProfileUserExpiry] = useState({ unit: 'days', value: '' })
const [announcementSubject, setAnnouncementSubject] = useState('')
const [announcementBody, setAnnouncementBody] = useState('')
const [announcementChannels, setAnnouncementChannels] = useState<string[]>(['discord'])
const [announcementStatus, setAnnouncementStatus] = useState<string | null>(null)
const loadSettings = async () => { const loadSettings = async () => {
const baseUrl = getApiBase() const baseUrl = getApiBase()
@@ -198,6 +325,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'artwork') { if (section === 'artwork') {
await loadArtworkPrefetchStatus() await loadArtworkPrefetchStatus()
} }
if (section === 'invites') {
await loadInviteProfiles()
await loadInvites()
}
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setStatus('Could not load admin settings.') setStatus('Could not load admin settings.')
@@ -251,6 +382,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const showRequestsExtras = section === 'requests' const showRequestsExtras = section === 'requests'
const showArtworkExtras = section === 'artwork' const showArtworkExtras = section === 'artwork'
const showCacheExtras = section === 'cache' const showCacheExtras = section === 'cache'
const showInviteExtras = section === 'invites'
const showNotificationExtras = section === 'notifications'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => { const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true if (sectionGroup.items && sectionGroup.items.length > 0) return true
if (showArtworkExtras && sectionGroup.key === 'artwork') return true if (showArtworkExtras && sectionGroup.key === 'artwork') return true
@@ -289,6 +422,59 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_data_source: 'Pick where Magent should read requests from.', requests_data_source: 'Pick where Magent should read requests from.',
log_level: 'How much detail is written to the activity log.', log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.', log_file: 'Where the activity log is stored.',
invites_enabled: 'Allow new users to register with invite links.',
invites_require_captcha: 'Require a captcha on invite sign-up.',
invite_default_profile_id: 'Default invite profile applied when creating invites.',
signup_allow_referrals: 'Let users create referral invites for friends/family.',
referral_default_uses: 'Default number of uses for referral invites.',
password_min_length: 'Minimum length required for passwords.',
password_require_upper: 'Require uppercase letters in passwords.',
password_require_lower: 'Require lowercase letters in passwords.',
password_require_number: 'Require numbers in passwords.',
password_require_symbol: 'Require symbols in passwords.',
password_reset_enabled: 'Allow local users to request password reset emails.',
captcha_provider: 'Choose which captcha provider to use for sign-up.',
hcaptcha_site_key: 'Public hCaptcha site key.',
hcaptcha_secret_key: 'Secret hCaptcha key.',
recaptcha_site_key: 'Public reCAPTCHA site key.',
recaptcha_secret_key: 'Secret reCAPTCHA key.',
turnstile_site_key: 'Public Turnstile site key.',
turnstile_secret_key: 'Secret Turnstile key.',
smtp_host: 'SMTP server hostname.',
smtp_port: 'SMTP server port.',
smtp_user: 'SMTP username (optional).',
smtp_password: 'SMTP password (optional).',
smtp_from: 'Default "from" address for system emails.',
smtp_tls: 'Use TLS for SMTP (SMTPS).',
smtp_starttls: 'Use STARTTLS for SMTP.',
notify_email_enabled: 'Send notices by email.',
notify_discord_enabled: 'Send notices to Discord.',
notify_telegram_enabled: 'Send notices to Telegram.',
notify_matrix_enabled: 'Send notices to Matrix.',
notify_pushover_enabled: 'Send notices to Pushover.',
notify_pushbullet_enabled: 'Send notices to Pushbullet.',
notify_gotify_enabled: 'Send notices to Gotify.',
notify_ntfy_enabled: 'Send notices to ntfy.',
telegram_bot_token: 'Telegram bot token for sending notices.',
telegram_chat_id: 'Default Telegram chat ID.',
matrix_homeserver: 'Matrix server base URL.',
matrix_user: 'Matrix bot username.',
matrix_password: 'Matrix bot password.',
matrix_access_token: 'Matrix access token.',
matrix_room_id: 'Matrix room ID for announcements.',
pushover_token: 'Pushover application token.',
pushover_user_key: 'Pushover user key.',
pushbullet_token: 'Pushbullet access token.',
gotify_url: 'Gotify server URL.',
gotify_token: 'Gotify app token.',
ntfy_url: 'ntfy server URL.',
ntfy_topic: 'ntfy topic for notifications.',
expiry_default_days: 'Default number of days before accounts expire.',
expiry_default_action: 'Action to take when an account expires.',
expiry_warning_days: 'How many days before expiry to warn the user.',
expiry_check_interval_minutes: 'How often expiry checks run.',
jellyseerr_sync_users: 'Sync Jellyseerr users into Magent.',
jellyseerr_sync_interval_minutes: 'How often Jellyseerr user sync runs.',
} }
const buildSelectOptions = ( const buildSelectOptions = (
@@ -312,6 +498,62 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return list return list
} }
const durationOptions = {
minutes: Array.from({ length: 60 }, (_, i) => String(i + 1)),
hours: Array.from({ length: 24 }, (_, i) => String(i + 1)),
days: Array.from({ length: 365 }, (_, i) => String(i + 1)),
}
const toDays = (choice: { unit: string; value: string }) => {
if (!choice || choice.unit === 'unlimited') return null
const amount = Number(choice.value)
if (!amount || amount <= 0) return null
if (choice.unit === 'minutes') return amount / 1440
if (choice.unit === 'hours') return amount / 24
if (choice.unit === 'months') return amount * 30
return amount
}
const renderDurationControl = (
label: string,
choice: { unit: string; value: string },
setChoice: (next: { unit: string; value: string }) => void
) => (
<label>
{label}
<div className="duration-row">
<input
type="number"
min={1}
placeholder="Amount"
value={choice.value}
onChange={(event) =>
setChoice({
unit: choice.unit,
value: event.target.value,
})
}
disabled={choice.unit === 'unlimited'}
/>
<select
value={choice.unit}
onChange={(event) =>
setChoice({
unit: event.target.value,
value: event.target.value === 'unlimited' ? '' : choice.value,
})
}
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="months">Months</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
</label>
)
const submit = async (event: React.FormEvent<HTMLFormElement>) => { const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault()
setStatus(null) setStatus(null)
@@ -422,6 +664,174 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
} }
const loadInviteProfiles = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invite-profiles`)
if (!response.ok) {
throw new Error('Profiles unavailable')
}
const data = await response.json()
setInviteProfiles(Array.isArray(data?.profiles) ? data.profiles : [])
} catch (err) {
console.error(err)
setInviteProfiles([])
}
}
const loadInvites = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites`)
if (!response.ok) {
throw new Error('Invites unavailable')
}
const data = await response.json()
setInvites(Array.isArray(data?.invites) ? data.invites : [])
} catch (err) {
console.error(err)
setInvites([])
}
}
const createInviteProfile = async () => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invite-profiles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...profileForm,
max_uses: profileForm.max_uses ? Number(profileForm.max_uses) : null,
expires_in_days: toDays(profileExpiry),
referral_uses: profileForm.referral_uses ? Number(profileForm.referral_uses) : null,
user_expiry_days: toDays(profileUserExpiry),
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Profile creation failed')
}
setProfileForm({
name: '',
description: '',
max_uses: '',
require_captcha: false,
allow_referrals: false,
referral_uses: '',
user_expiry_action: 'disable',
})
setProfileExpiry({ unit: 'days', value: '' })
setProfileUserExpiry({ unit: 'days', value: '' })
setInviteStatus('Invite profile created.')
await loadInviteProfiles()
} catch (err) {
console.error(err)
setInviteStatus('Could not create invite profile.')
}
}
const createInviteCode = async () => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile_id: inviteForm.profile_id ? Number(inviteForm.profile_id) : null,
max_uses: inviteForm.max_uses ? Number(inviteForm.max_uses) : null,
expires_in_days: toDays(inviteExpiry),
require_captcha: inviteForm.require_captcha,
allow_referrals: inviteForm.allow_referrals,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Invite creation failed')
}
setInviteStatus('Invite created.')
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not create invite.')
}
}
const disableInviteCode = async (code: string) => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/${encodeURIComponent(code)}/disable`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Disable failed')
}
setInviteStatus(`Invite ${code} disabled.`)
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not disable invite.')
}
}
const deleteInviteCode = async (code: string) => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/invites/${encodeURIComponent(code)}`,
{ method: 'DELETE' }
)
if (!response.ok) {
throw new Error('Delete failed')
}
setInviteStatus(`Invite ${code} deleted.`)
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not delete invite.')
}
}
const toggleAnnouncementChannel = (channel: string) => {
setAnnouncementChannels((current) =>
current.includes(channel) ? current.filter((item) => item !== channel) : [...current, channel]
)
}
const sendAnnouncement = async () => {
setAnnouncementStatus(null)
if (!announcementSubject || !announcementBody) {
setAnnouncementStatus('Enter a subject and a message.')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/announcements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: announcementSubject,
body: announcementBody,
channels: announcementChannels,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Announcement failed')
}
setAnnouncementStatus('Announcement sent.')
setAnnouncementBody('')
setAnnouncementSubject('')
} catch (err) {
console.error(err)
setAnnouncementStatus('Could not send the announcement.')
}
}
const prefetchArtwork = async () => { const prefetchArtwork = async () => {
setArtworkPrefetchStatus(null) setArtworkPrefetchStatus(null)
try { try {
@@ -1085,6 +1495,83 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if (setting.key === 'captcha_provider') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'none'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="none">No captcha</option>
<option value="hcaptcha">hCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Cloudflare Turnstile</option>
</select>
</label>
)
}
if (setting.key === 'expiry_default_action') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'disable'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="disable">Disable account</option>
<option value="delete">Delete account</option>
<option value="disable_then_delete">Disable, then delete</option>
</select>
</label>
)
}
if (NUMBER_SETTINGS.has(setting.key)) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<input
name={setting.key}
type="number"
min={0}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row"> <span className="label-row">
@@ -1260,6 +1747,297 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div> </div>
</section> </section>
)} )}
{showInviteExtras && (
<section className="admin-section" id="invites">
<div className="section-header">
<h2>Invites</h2>
</div>
<div className="status-banner">
Create profiles for common rules, then issue invites in seconds.
</div>
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
<div className="invite-stack">
<div className="summary-card invite-card">
<h3>Create a profile</h3>
<p className="meta">Profiles save default rules for new invites.</p>
<div className="invite-grid">
<label>
Profile name
<input
value={profileForm.name}
onChange={(event) =>
setProfileForm((current) => ({ ...current, name: event.target.value }))
}
/>
</label>
<label>
Description
<input
value={profileForm.description}
onChange={(event) =>
setProfileForm((current) => ({ ...current, description: event.target.value }))
}
/>
</label>
<label>
Max uses
<input
type="number"
min={0}
value={profileForm.max_uses}
onChange={(event) =>
setProfileForm((current) => ({ ...current, max_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for unlimited.</span>
</label>
{renderDurationControl('Invite expires after', profileExpiry, setProfileExpiry)}
{renderDurationControl(
'User account expires after',
profileUserExpiry,
setProfileUserExpiry
)}
<label>
Expiry action
<select
value={profileForm.user_expiry_action}
onChange={(event) =>
setProfileForm((current) => ({
...current,
user_expiry_action: event.target.value,
}))
}
>
<option value="disable">Disable account</option>
<option value="delete">Delete account</option>
<option value="disable_then_delete">Disable, then delete</option>
</select>
</label>
<label>
Referral uses
<input
type="number"
min={0}
value={profileForm.referral_uses}
onChange={(event) =>
setProfileForm((current) => ({ ...current, referral_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for 1.</span>
</label>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={profileForm.require_captcha}
onChange={(event) =>
setProfileForm((current) => ({
...current,
require_captcha: event.target.checked,
}))
}
/>
<span>Require captcha</span>
</label>
<span className="toggle-help">Adds a human check to sign-up.</span>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={profileForm.allow_referrals}
onChange={(event) =>
setProfileForm((current) => ({
...current,
allow_referrals: event.target.checked,
}))
}
/>
<span>Allow referrals</span>
</label>
<span className="toggle-help">Let users share their own invite.</span>
</div>
<div className="admin-actions">
<button type="button" onClick={createInviteProfile}>
Save profile
</button>
</div>
</div>
<div className="summary-card invite-card">
<h3>Create an invite</h3>
<p className="meta">Pick a profile or customize a one-off invite.</p>
<div className="invite-grid">
<label>
Profile
<select
value={inviteForm.profile_id}
onChange={(event) =>
setInviteForm((current) => ({ ...current, profile_id: event.target.value }))
}
>
<option value="">No profile</option>
{inviteProfiles.map((profile) => (
<option key={profile.id} value={String(profile.id)}>
{profile.name}
</option>
))}
</select>
</label>
<label>
Max uses
<input
type="number"
min={0}
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for unlimited.</span>
</label>
{renderDurationControl('Invite expires after', inviteExpiry, setInviteExpiry)}
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={inviteForm.require_captcha}
onChange={(event) =>
setInviteForm((current) => ({
...current,
require_captcha: event.target.checked,
}))
}
/>
<span>Require captcha</span>
</label>
<span className="toggle-help">Adds a human check to sign-up.</span>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={inviteForm.allow_referrals}
onChange={(event) =>
setInviteForm((current) => ({
...current,
allow_referrals: event.target.checked,
}))
}
/>
<span>Allow referrals</span>
</label>
<span className="toggle-help">Lets this person create their own invite.</span>
</div>
<div className="admin-actions">
<button type="button" onClick={createInviteCode}>
Create invite
</button>
<button type="button" className="ghost-button" onClick={loadInvites}>
Refresh invites
</button>
</div>
</div>
</div>
<div className="cache-table">
<div className="cache-row cache-head">
<span>Code</span>
<span>Uses</span>
<span>Expires</span>
<span>Status</span>
<span>Actions</span>
</div>
{invites.length === 0 ? (
<div className="meta">No invites yet.</div>
) : (
invites.map((invite) => (
<div key={invite.code} className="cache-row">
<span>{invite.code}</span>
<span>
{invite.uses_count ?? 0}/{invite.max_uses ?? 'Unlimited'}
</span>
<span>{invite.expires_at || 'Never'}</span>
<span>{invite.disabled ? 'Disabled' : 'Active'}</span>
<span className="cache-actions">
<button
type="button"
className="ghost-button"
onClick={() => disableInviteCode(invite.code)}
disabled={invite.disabled}
>
Disable
</button>
<button
type="button"
className="danger-button"
onClick={() => deleteInviteCode(invite.code)}
>
Delete
</button>
</span>
</div>
))
)}
</div>
</section>
)}
{showNotificationExtras && (
<section className="admin-section" id="announcements">
<div className="section-header">
<h2>Send an announcement</h2>
</div>
<div className="status-banner">
Send a message to all users through the channels you select.
</div>
{announcementStatus && <div className="status-banner">{announcementStatus}</div>}
<div className="admin-grid">
<label>
Subject
<input
value={announcementSubject}
onChange={(event) => setAnnouncementSubject(event.target.value)}
/>
</label>
<label>
Message
<textarea
rows={5}
value={announcementBody}
onChange={(event) => setAnnouncementBody(event.target.value)}
/>
</label>
<div className="settings-checkbox-grid">
{[
{ id: 'email', label: 'Email' },
{ id: 'discord', label: 'Discord' },
{ id: 'telegram', label: 'Telegram' },
{ id: 'matrix', label: 'Matrix' },
{ id: 'pushover', label: 'Pushover' },
{ id: 'pushbullet', label: 'Pushbullet' },
{ id: 'gotify', label: 'Gotify' },
{ id: 'ntfy', label: 'ntfy' },
].map((channel) => (
<label key={channel.id} className="toggle">
<input
type="checkbox"
checked={announcementChannels.includes(channel.id)}
onChange={() => toggleAnnouncementChannel(channel.id)}
/>
<span>{channel.label}</span>
</label>
))}
</div>
</div>
<div className="admin-actions">
<button type="button" onClick={sendAnnouncement}>
Send announcement
</button>
</div>
</section>
)}
</AdminShell> </AdminShell>
) )
} }

View File

@@ -0,0 +1,120 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type Profile = {
username?: string
}
export default function FeedbackPage() {
const router = useRouter()
const [profile, setProfile] = useState<Profile | null>(null)
const [category, setCategory] = useState('bug')
const [message, setMessage] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
router.push('/login')
return
}
const data = await response.json()
setProfile({ username: data?.username })
} catch (error) {
console.error(error)
}
}
void load()
}, [router])
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setStatus(null)
if (!message.trim()) {
setStatus('Please write a short message before sending.')
return
}
setSubmitting(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: category,
message: message.trim(),
}),
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`)
}
setMessage('')
setStatus('Thanks! Your message has been sent.')
} catch (error) {
console.error(error)
setStatus('That did not send. Please try again.')
} finally {
setSubmitting(false)
}
}
return (
<main className="card">
<header className="how-hero">
<p className="eyebrow">Send feedback</p>
<h1>Help us improve Magent</h1>
<p className="lede">
Found a problem or have an idea? Send it here and we will see it right away.
</p>
</header>
<form className="auth-form" onSubmit={submit}>
<label htmlFor="feedback-user">Your username</label>
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
<label htmlFor="feedback-type">What is this about?</label>
<select
id="feedback-type"
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="bug">Bug (something is broken)</option>
<option value="feature">Feature idea (new option)</option>
</select>
<label htmlFor="feedback-message">Tell us what happened</label>
<textarea
id="feedback-message"
rows={6}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Write the details here..."
/>
{status && <div className="status-banner">{status}</div>}
<button type="submit" disabled={submitting}>
{submitting ? 'Sending...' : 'Send feedback'}
</button>
</form>
</main>
)
}

View File

@@ -108,7 +108,7 @@ body {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 2 / 3; grid-row: 2 / 3;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-start;
} }
.brand { .brand {
@@ -130,6 +130,7 @@ body {
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
width: 100%;
} }
.header-actions a { .header-actions a {
@@ -162,6 +163,18 @@ body {
text-align: center; text-align: center;
} }
.header-actions .header-cta {
background: linear-gradient(120deg, rgba(255, 107, 43, 0.95), rgba(255, 168, 75, 0.95));
color: #151515;
border: 1px solid rgba(255, 140, 60, 0.7);
box-shadow: 0 12px 24px rgba(255, 107, 43, 0.35);
font-weight: 700;
}
.header-actions .header-cta--left {
margin-right: auto;
}
.signed-in { .signed-in {
font-size: 12px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
@@ -332,6 +345,17 @@ select option {
color: var(--ink); color: var(--ink);
} }
textarea {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--border);
font-size: 16px;
background: var(--input-bg);
color: var(--input-ink);
font-family: inherit;
resize: vertical;
}
button { button {
padding: 12px 18px; padding: 12px 18px;
border-radius: 999px; border-radius: 999px;
@@ -635,6 +659,21 @@ button span {
justify-items: end; justify-items: end;
} }
.user-bulk-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.user-bulk-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.toggle { .toggle {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
@@ -766,6 +805,11 @@ button span {
gap: 10px; gap: 10px;
} }
.captcha-wrap {
display: flex;
justify-content: center;
}
.ghost-button { .ghost-button {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
color: var(--ink); color: var(--ink);
@@ -913,6 +957,103 @@ button span {
gap: 16px; gap: 16px;
} }
.settings-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.duration-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(160px, 1fr);
gap: 10px;
align-items: center;
}
.duration-row input,
.duration-row select {
width: 100%;
}
.invite-stack {
display: grid;
gap: 16px;
margin-bottom: 16px;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
align-items: stretch;
}
.invite-card h3 {
margin: 0;
font-size: 18px;
}
.invite-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
align-items: start;
}
.invite-card {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.invite-card .admin-actions {
margin-top: auto;
}
.invite-grid label {
display: grid;
gap: 8px;
font-size: 14px;
color: var(--ink-muted);
text-align: left;
}
.invite-grid input,
.invite-grid select {
width: 100%;
}
.toggle-row {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px 14px;
}
.toggle-help {
font-size: 12px;
color: var(--ink-muted);
}
.field-help {
font-size: 12px;
color: var(--ink-muted);
}
.field-inline {
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(180px, 260px);
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--ink-muted);
}
.field-stack {
display: grid;
gap: 6px;
}
.field-stack .field-help {
text-align: left;
}
.admin-grid label { .admin-grid label {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1034,7 +1175,7 @@ button span {
.cache-row { .cache-row {
display: grid; display: grid;
grid-template-columns: 90px minmax(0, 1.6fr) 120px 90px 180px; grid-template-columns: minmax(140px, 1.4fr) 100px 140px 100px 180px;
gap: 12px; gap: 12px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
@@ -1042,6 +1183,7 @@ button span {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
font-size: 13px; font-size: 13px;
color: var(--ink); color: var(--ink);
align-items: center;
} }
.cache-row span { .cache-row span {
@@ -1058,6 +1200,13 @@ button span {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.cache-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.maintenance-grid { .maintenance-grid {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -1375,6 +1524,22 @@ button span {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.duration-row {
grid-template-columns: 1fr;
}
.field-inline {
grid-template-columns: 1fr;
}
.cache-row {
grid-template-columns: 1fr;
}
.toggle-row {
grid-template-columns: 1fr;
}
.card { .card {
padding: 24px; padding: 24px;
} }

View File

@@ -7,8 +7,8 @@ export default function HowItWorksPage() {
<p className="eyebrow">How this works</p> <p className="eyebrow">How this works</p>
<h1>Your request, step by step</h1> <h1>Your request, step by step</h1>
<p className="lede"> <p className="lede">
Think of Magent as a status tracker. It checks a few helper apps that do different jobs, Magent is a friendly status checker. It looks at a few helper apps, then shows you where
then tells you where your request is stuck and what you can safely try next. your request is and what you can safely do next.
</p> </p>
</header> </header>
@@ -17,32 +17,36 @@ export default function HowItWorksPage() {
<h2>Jellyseerr</h2> <h2>Jellyseerr</h2>
<p className="how-title">The request box</p> <p className="how-title">The request box</p>
<p> <p>
This is where you ask for a movie or show. It records your request and keeps track of This is where you ask for a movie or show. It keeps the request and whether it is
approvals. approved.
</p> </p>
</article> </article>
<article className="how-card"> <article className="how-card">
<h2>Sonarr / Radarr</h2> <h2>Sonarr / Radarr</h2>
<p className="how-title">The librarian</p> <p className="how-title">The library manager</p>
<p> <p>
These apps add the item to the library, decide what quality to grab, and look for the These add the request to the library list and decide what quality to look for.
files that match your request.
</p> </p>
</article> </article>
<article className="how-card"> <article className="how-card">
<h2>Prowlarr</h2> <h2>Prowlarr</h2>
<p className="how-title">The search helper</p> <p className="how-title">The search helper</p>
<p> <p>
This one checks your torrent sources and reports back what it found, or if nothing is This checks your search sources and reports back what it finds.
available yet.
</p> </p>
</article> </article>
<article className="how-card"> <article className="how-card">
<h2>qBittorrent</h2> <h2>qBittorrent</h2>
<p className="how-title">The downloader</p> <p className="how-title">The downloader</p>
<p> <p>
If a file is found, this app downloads it. Magent can tell if it is actively This downloads the file. Magent can tell if it is downloading, paused, or finished.
downloading, stalled, or finished. </p>
</article>
<article className="how-card">
<h2>Jellyfin</h2>
<p className="how-title">The place you watch</p>
<p>
When the file is ready, Jellyfin shows it in your library so you can watch it.
</p> </p>
</article> </article>
</section> </section>
@@ -54,13 +58,13 @@ export default function HowItWorksPage() {
<strong>You request a title</strong> in Jellyseerr. <strong>You request a title</strong> in Jellyseerr.
</li> </li>
<li> <li>
<strong>Sonarr/Radarr adds it</strong> to the library list and asks Prowlarr to search. <strong>Sonarr/Radarr adds it</strong> to the library list.
</li> </li>
<li> <li>
<strong>Prowlarr looks for sources</strong> and sends results back. <strong>Prowlarr looks for sources</strong> and sends results back.
</li> </li>
<li> <li>
<strong>qBittorrent downloads</strong> the best match. <strong>qBittorrent downloads</strong> the match.
</li> </li>
<li> <li>
<strong>Sonarr/Radarr imports</strong> it into your library. <strong>Sonarr/Radarr imports</strong> it into your library.
@@ -72,11 +76,10 @@ export default function HowItWorksPage() {
</section> </section>
<section className="how-callout"> <section className="how-callout">
<h2>Why Magent sometimes says "waiting"</h2> <h2>Why Magent sometimes says waiting</h2>
<p> <p>
If the search helper cannot find a match yet, Magent will say there is nothing to grab. If the search helper cannot find a match yet, Magent will say there is nothing to grab.
This does not mean something is broken. It usually means the release is not available That does not mean it is broken. It usually means the release is not available yet.
yet or your search sources do not have it.
</p> </p>
</section> </section>
</main> </main>

View File

@@ -1,17 +1,10 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { getApiBase } from '../lib/auth'
const STORAGE_KEY = 'branding_version'
export default function BrandingFavicon() { export default function BrandingFavicon() {
useEffect(() => { useEffect(() => {
const baseUrl = getApiBase() const href = '/api/branding/favicon.ico'
const version =
(typeof window !== 'undefined' && window.localStorage.getItem(STORAGE_KEY)) || ''
const versionSuffix = version ? `?v=${encodeURIComponent(version)}` : ''
const href = `${baseUrl}/branding/favicon.ico${versionSuffix}`
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
if (!link) { if (!link) {
link = document.createElement('link') link = document.createElement('link')

View File

@@ -1,36 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { getApiBase } from '../lib/auth'
const STORAGE_KEY = 'branding_version'
type BrandingLogoProps = { type BrandingLogoProps = {
className?: string className?: string
alt?: string alt?: string
} }
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) { export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
const baseUrl = getApiBase()
const version =
(typeof window !== 'undefined' && window.localStorage.getItem(STORAGE_KEY)) || ''
const versionSuffix = version ? `?v=${encodeURIComponent(version)}` : ''
setSrc(`${baseUrl}/branding/logo.png${versionSuffix}`)
}, [])
if (!src) {
return null
}
return ( return (
<img <img
className={className} className={className}
src={src} src="/api/branding/logo.png"
alt={alt} alt={alt}
onError={() => setSrc(null)}
/> />
) )
} }

View File

@@ -46,6 +46,7 @@ export default function HeaderActions() {
return ( return (
<div className="header-actions"> <div className="header-actions">
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
<a href="/">Requests</a> <a href="/">Requests</a>
<a href="/how-it-works">How it works</a> <a href="/how-it-works">How it works</a>
<a href="/profile">My profile</a> <a href="/profile">My profile</a>

View File

@@ -25,8 +25,6 @@ export default function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [passwordInputs, setPasswordInputs] = useState<Record<string, string>>({})
const [passwordStatus, setPasswordStatus] = useState<Record<string, string>>({})
const loadUsers = async () => { const loadUsers = async () => {
try { try {
@@ -105,42 +103,6 @@ export default function UsersPage() {
} }
} }
const updateUserPassword = async (username: string) => {
const newPassword = passwordInputs[username] || ''
if (!newPassword || newPassword.length < 8) {
setPasswordStatus((current) => ({
...current,
[username]: 'Password must be at least 8 characters.',
}))
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/password`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setPasswordInputs((current) => ({ ...current, [username]: '' }))
setPasswordStatus((current) => ({
...current,
[username]: 'Password updated.',
}))
} catch (err) {
console.error(err)
setPasswordStatus((current) => ({
...current,
[username]: 'Could not update password.',
}))
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
@@ -197,27 +159,6 @@ export default function UsersPage() {
{user.isBlocked ? 'Allow access' : 'Block access'} {user.isBlocked ? 'Allow access' : 'Block access'}
</button> </button>
</div> </div>
{user.authProvider === 'local' && (
<div className="user-actions">
<input
type="password"
placeholder="New password (min 8 chars)"
value={passwordInputs[user.username] || ''}
onChange={(event) =>
setPasswordInputs((current) => ({
...current,
[user.username]: event.target.value,
}))
}
/>
<button type="button" onClick={() => updateUserPassword(user.username)}>
Set password
</button>
</div>
)}
{passwordStatus[user.username] && (
<div className="meta">{passwordStatus[user.username]}</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs>
<linearGradient id="magentIconGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff6b2b"/>
<stop offset="100%" stop-color="#ffa84b"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#0e1624"/>
<path
d="M18 50V14h8l6 11 6-11h8v36h-8V32l-6 10-6-10v18h-8z"
fill="url(#magentIconGlow)"
/>
</svg>

After

Width:  |  Height:  |  Size: 457 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300">
<defs>
<linearGradient id="magentGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff6b2b"/>
<stop offset="100%" stop-color="#ffa84b"/>
</linearGradient>
</defs>
<rect width="300" height="300" rx="56" fill="#0e1624"/>
<rect x="24" y="24" width="252" height="252" rx="44" fill="#121d31"/>
<path
d="M80 220V80h28l42 70 42-70h28v140h-28v-88l-42 66-42-66v88H80z"
fill="url(#magentGlow)"
/>
</svg>

After

Width:  |  Height:  |  Size: 537 B