Prepare clean production setup and coming-soon cover
Magent CI/CD / verify (push) Successful in 11m48s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-07 13:14:22 +12:00
parent 98d8b197a9
commit c2685f43a7
11 changed files with 576 additions and 404 deletions
+1
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- beta - beta
- main
- prod - prod
workflow_dispatch: workflow_dispatch:
+33
View File
@@ -0,0 +1,33 @@
# Fresh production setup
Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and
`docker-compose.production.yml`. The legacy `prod` deployment and beta are not
overwritten. Main runs CI verification; production activation is deliberately
manual during the initial cutover.
Only API connection URLs/credentials and SMTP configuration are exported by
`scripts/prepare_production_settings.py`. It reads the source's effective settings,
uses an explicit allowlist, refuses existing output directories, and creates
private files. It never copies a database, users, invite codes, issues, history,
tokens, sessions, branding or notification templates. A new bootstrap admin and
JWT secret are generated. Retrieve the bootstrap credentials from the protected
`bootstrap-admin.json` on the server; never commit them.
The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables
`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic
imports and repair emails. The cover is not an authentication/security boundary;
normal API authentication remains in force. Administrators can use `/login`.
Run `docker compose -f docker-compose.production.yml up -d --build` from the
production directory. Caddy should proxy this hostname to `10.30.1.32:3200`;
Next forwards `/api` internally. The backend health port is localhost-only at
8200. Do not alter beta's route or other Caddy sites.
Before public activation, validate Caddy config, save its existing configuration,
verify HTTPS, admin login, connection diagnostics and the empty-client-data state.
Do not send SMTP tests without approval. Keep the old upstream for rollback.
At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`,
then recreate the container. External service records can then be imported through
normal synchronization; no beta client data is migrated. Review quality profiles,
root folders, invite policy and notification rules in admin settings before use.
+4
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import os
import time import time
import uuid import uuid
from typing import Awaitable, Callable from typing import Awaitable, Callable
@@ -251,6 +252,9 @@ async def startup() -> None:
runtime.log_background_sync_level, runtime.log_background_sync_level,
runtime.requests_data_source, runtime.requests_data_source,
) )
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
logger.info("Background imports and automation paused for initial setup")
return
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync) _launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
_launch_background_task("requests-warmup", startup_warmup_requests_cache) _launch_background_task("requests-warmup", startup_warmup_requests_cache)
_launch_background_task("requests-delta-loop", run_requests_delta_loop) _launch_background_task("requests-delta-loop", run_requests_delta_loop)
+13
View File
@@ -0,0 +1,13 @@
name: magent-production
services:
magent:
build: .
env_file:
- ./.env
ports:
- "10.30.1.32:3200:3000"
- "127.0.0.1:8200:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
+407
View File
@@ -0,0 +1,407 @@
'use client'
import PageHeading from './ui/PageHeading'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
const normalizeRecentResults = (items: any[]) =>
items
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
type: item.type,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working' },
{ value: 'partial', label: 'Partial' },
{ value: 'ready', label: 'Ready' },
{ value: 'declined', label: 'Declined' },
]
export default function HomePage() {
const router = useRouter()
const [query, setQuery] = useState('')
const [recent, setRecent] = useState<
{
id: number
title: string
year?: number
type?: string
statusLabel?: string
artwork?: { poster_url?: string; backdrop_url?: string }
createdAt?: string | null
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState<
{
title: string
year?: number
type?: string
requestId?: number
statusLabel?: string
requestedBy?: string | null
accessible?: boolean
}[]
>([])
const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false)
const submit = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = query.trim()
if (!trimmed) return
if (/^\d+$/.test(trimmed)) {
router.push(`/requests/${encodeURIComponent(trimmed)}`)
return
}
void runSearch(trimmed)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
setRecentLoading(true)
setRecentError(null)
try {
const baseUrl = getApiBase()
const meResponse = await authFetch(`${baseUrl}/auth/me`)
if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Auth failed: ${meResponse.status}`)
}
const me = await meResponse.json()
const userRole = me?.role ?? null
setRole(userRole)
setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6
const params = new URLSearchParams({
take: String(take),
days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('stage', recentStage)
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Recent requests failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results))
}
} catch (error) {
console.error(error)
setRecentError('Recent requests are not available right now.')
} finally {
setRecentLoading(false)
}
}
load()
}, [recentDays, recentStage])
useEffect(() => {
if (!authReady) {
return
}
if (!getToken()) {
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const params = new URLSearchParams({
stream_token: streamToken,
recent_days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('recent_stage', recentStage)
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
source.onmessage = (event) => {
if (closed) return
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object') {
return
}
if (payload.type === 'home_recent') {
if (Array.isArray(payload.results)) {
setRecent(normalizeRecentResults(payload.results))
setRecentError(null)
setRecentLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setRecentError('Recent requests are not available right now.')
setRecentLoading(false)
}
return
}
} catch (error) {
console.error(error)
}
}
} catch (error) {
if (closed) return
console.error(error)
}
}
void connect()
return () => {
closed = true
source?.close()
}
}, [authReady, recentDays, recentStage])
const runSearch = async (term: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Search failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setSearchResults(
data.results.map((item: any) => ({
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
requestedBy: item.requestedBy ?? null,
accessible: Boolean(item.accessible),
}))
)
setSearchError(null)
}
} catch (error) {
console.error(error)
setSearchError('Search failed. Try a request ID instead.')
setSearchResults([])
}
}
const resolveArtworkUrl = (url?: string | null) => {
if (!url) return null
return url.startsWith('http') ? url : `${getApiBase()}${url}`
}
const formatRequestTime = (value?: string | null) => {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
}).length
const readyRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return label.includes('ready') || label.includes('available')
}).length
const requestCardState = (value?: string) => {
const label = String(value ?? '').toLowerCase()
if (label.includes('ready') || label.includes('available')) return { key: 'ready', label: value || 'Ready', progress: 100 }
if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 }
if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 }
if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 }
return { key: 'waiting', label: value || 'Waiting', progress: 4 }
}
return (
<main className="card home-page">
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
<input
id="request-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Dune 2021 or 1289"
/>
<button type="submit">Find request</button>
</div>
</form>
} />
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
<div className="home-section-heading">
<div>
<span className="section-kicker">Search results</span>
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
</div>
<button type="button" className="ghost-button" onClick={() => {
setSearchResults([])
setSearchError(null)
}}>
Clear
</button>
</div>
{searchError ? (
<div className="error-banner">{searchError}</div>
) : (
<div className="home-result-grid">
{searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
className="home-result-card"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
>
<span>
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
</span>
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
</button>
))}
</div>
)}
</section>
)}
<section className="home-metric-strip" aria-label="Request summary">
<div><span>In view</span><strong>{recent.length}</strong></div>
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
</section>
<section className="recent home-recent">
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Period</span>
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
<option value={0}>All time</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
</div>
)}
</div>
{authReady && (
<div className="request-filter-chips" aria-label="Filter requests by stage">
{REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => (
<button
type="button"
key={option.value}
className={recentStage === option.value ? 'is-active' : undefined}
onClick={() => setRecentStage(option.value)}
>
{option.value === 'working' ? <i aria-hidden="true" /> : null}
{option.value === 'all' ? 'All' : option.label}
</button>
))}
</div>
)}
<div className="recent-grid home-recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests...</span>
</div>
) : recentError ? (
<div className="error-banner">{recentError}</div>
) : recent.length === 0 ? (
<div className="home-empty-state">
<strong>No requests match these filters</strong>
<span>Try a wider period or a different stage.</span>
</div>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className={`recent-card is-${requestCardState(item.statusLabel).key}`}
>
{item.artwork?.poster_url ? (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
)}
<span className="recent-info">
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
<span className="recent-meta">
{item.statusLabel || 'Status not available yet'} · Request {item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
</button>
))
)}
</div>
</section>
</main>
)
}
+17
View File
@@ -0,0 +1,17 @@
import './style.css'
export const metadata = { title: 'Coming soon | Magent — Grizzlyflix' }
export default function ComingSoonPage() {
return <main className="launch-cover">
<div className="launch-brand">GRIZZLYFLIX</div>
<span className="launch-badge">COMING SOON</span>
<h1>Your next watch.<br /><em>Made simpler.</em></h1>
<p className="launch-intro">The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.</p>
<div className="launch-path" aria-label="Request journey">
{['Request', 'Track', 'Watch'].map((label, index) => <div key={label}><span>0{index + 1}</span><strong>{label}</strong></div>)}
</div>
<p className="launch-note">Were getting everything ready. Check back soon.</p>
<footer><strong>Magent</strong><span>Grizzlyflix member portal</span><a href="/login">Admin sign in</a></footer>
</main>
}
+16
View File
@@ -0,0 +1,16 @@
.page:has(.launch-cover) { max-width: none; margin: 0; padding: 0; }
.launch-cover { box-sizing: border-box; min-height: 100svh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 64px 24px 24px; text-align: center; background: radial-gradient(ellipse at 50% 25%, #252039 0%, transparent 55%), #101012; color: #f4f0ff; }
.launch-brand { font-size: 14px; letter-spacing: .3em; color: #c7bdff; font-weight: 700; margin-bottom: 36px; }
.launch-badge { border: 1px solid #6eddec66; color: #8be7f1; border-radius: 30px; padding: 8px 18px; font-size: 12px; letter-spacing: .15em; }
.launch-cover h1 { font-size: clamp(40px, 7vw, 88px); line-height: 1.08; letter-spacing: -.045em; margin: 28px 0 22px; }
.launch-cover h1 em { color: #c7bdff; font-style: normal; }
.launch-intro { max-width: 560px; font-size: 18px; line-height: 1.6; color: #bcb8c9; margin: 0; }
.launch-path { display: grid; grid-template-columns: repeat(3, 1fr); width: min(580px, 100%); margin: 40px 0 24px; border: 1px solid #ffffff20; border-radius: 16px; background: #ffffff04; }
.launch-path > div { padding: 22px 12px; display: grid; gap: 8px; }
.launch-path > div + div { border-left: 1px solid #ffffff15; }
.launch-path span { color: #8be7f1; font-size: 12px; }
.launch-path strong { font-size: 18px; }
.launch-note { color: #a9a4b5; font-size: 14px; }
.launch-cover footer { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px; margin-top: 64px; font-size: 12px; color: #a9a4b5; }
.launch-cover footer a { color: #c7bdff; text-underline-offset: 3px; }
.launch-cover a:focus-visible { outline: 2px solid #8be7f1; outline-offset: 5px; }
+5 -403
View File
@@ -1,407 +1,9 @@
'use client' import { redirect } from 'next/navigation'
import MyRequests from './MyRequests'
import PageHeading from './ui/PageHeading' export const dynamic = 'force-dynamic'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
const normalizeRecentResults = (items: any[]) =>
items
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
type: item.type,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working' },
{ value: 'partial', label: 'Partial' },
{ value: 'ready', label: 'Ready' },
{ value: 'declined', label: 'Declined' },
]
export default function HomePage() { export default function HomePage() {
const router = useRouter() if (process.env.MAGENT_COMING_SOON === 'true') redirect('/coming-soon')
const [query, setQuery] = useState('') return <MyRequests />
const [recent, setRecent] = useState<
{
id: number
title: string
year?: number
type?: string
statusLabel?: string
artwork?: { poster_url?: string; backdrop_url?: string }
createdAt?: string | null
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState<
{
title: string
year?: number
type?: string
requestId?: number
statusLabel?: string
requestedBy?: string | null
accessible?: boolean
}[]
>([])
const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false)
const submit = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = query.trim()
if (!trimmed) return
if (/^\d+$/.test(trimmed)) {
router.push(`/requests/${encodeURIComponent(trimmed)}`)
return
}
void runSearch(trimmed)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
setRecentLoading(true)
setRecentError(null)
try {
const baseUrl = getApiBase()
const meResponse = await authFetch(`${baseUrl}/auth/me`)
if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Auth failed: ${meResponse.status}`)
}
const me = await meResponse.json()
const userRole = me?.role ?? null
setRole(userRole)
setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6
const params = new URLSearchParams({
take: String(take),
days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('stage', recentStage)
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Recent requests failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results))
}
} catch (error) {
console.error(error)
setRecentError('Recent requests are not available right now.')
} finally {
setRecentLoading(false)
}
}
load()
}, [recentDays, recentStage])
useEffect(() => {
if (!authReady) {
return
}
if (!getToken()) {
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const params = new URLSearchParams({
stream_token: streamToken,
recent_days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('recent_stage', recentStage)
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
source.onmessage = (event) => {
if (closed) return
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object') {
return
}
if (payload.type === 'home_recent') {
if (Array.isArray(payload.results)) {
setRecent(normalizeRecentResults(payload.results))
setRecentError(null)
setRecentLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setRecentError('Recent requests are not available right now.')
setRecentLoading(false)
}
return
}
} catch (error) {
console.error(error)
}
}
} catch (error) {
if (closed) return
console.error(error)
}
}
void connect()
return () => {
closed = true
source?.close()
}
}, [authReady, recentDays, recentStage])
const runSearch = async (term: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Search failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setSearchResults(
data.results.map((item: any) => ({
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
requestedBy: item.requestedBy ?? null,
accessible: Boolean(item.accessible),
}))
)
setSearchError(null)
}
} catch (error) {
console.error(error)
setSearchError('Search failed. Try a request ID instead.')
setSearchResults([])
}
}
const resolveArtworkUrl = (url?: string | null) => {
if (!url) return null
return url.startsWith('http') ? url : `${getApiBase()}${url}`
}
const formatRequestTime = (value?: string | null) => {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
}).length
const readyRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return label.includes('ready') || label.includes('available')
}).length
const requestCardState = (value?: string) => {
const label = String(value ?? '').toLowerCase()
if (label.includes('ready') || label.includes('available')) return { key: 'ready', label: value || 'Ready', progress: 100 }
if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 }
if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 }
if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 }
return { key: 'waiting', label: value || 'Waiting', progress: 4 }
}
return (
<main className="card home-page">
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
<input
id="request-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Dune 2021 or 1289"
/>
<button type="submit">Find request</button>
</div>
</form>
} />
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
<div className="home-section-heading">
<div>
<span className="section-kicker">Search results</span>
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
</div>
<button type="button" className="ghost-button" onClick={() => {
setSearchResults([])
setSearchError(null)
}}>
Clear
</button>
</div>
{searchError ? (
<div className="error-banner">{searchError}</div>
) : (
<div className="home-result-grid">
{searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
className="home-result-card"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
>
<span>
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
</span>
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
</button>
))}
</div>
)}
</section>
)}
<section className="home-metric-strip" aria-label="Request summary">
<div><span>In view</span><strong>{recent.length}</strong></div>
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
</section>
<section className="recent home-recent">
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Period</span>
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
<option value={0}>All time</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
</div>
)}
</div>
{authReady && (
<div className="request-filter-chips" aria-label="Filter requests by stage">
{REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => (
<button
type="button"
key={option.value}
className={recentStage === option.value ? 'is-active' : undefined}
onClick={() => setRecentStage(option.value)}
>
{option.value === 'working' ? <i aria-hidden="true" /> : null}
{option.value === 'all' ? 'All' : option.label}
</button>
))}
</div>
)}
<div className="recent-grid home-recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests...</span>
</div>
) : recentError ? (
<div className="error-banner">{recentError}</div>
) : recent.length === 0 ? (
<div className="home-empty-state">
<strong>No requests match these filters</strong>
<span>Try a wider period or a different stage.</span>
</div>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className={`recent-card is-${requestCardState(item.statusLabel).key}`}
>
{item.artwork?.poster_url ? (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
)}
<span className="recent-info">
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
<span className="recent-meta">
{item.statusLabel || 'Status not available yet'} · Request {item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
</button>
))
)}
</div>
</section>
</main>
)
} }
+1 -1
View File
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
export default function ApplicationChrome() { export default function ApplicationChrome() {
const pathname = usePathname() const pathname = usePathname()
if (['/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null if (['/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
return <> return <>
<header className="header"> <header className="header">
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div> <div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
+59
View File
@@ -0,0 +1,59 @@
"""Run inside the configured source container. Export configuration, NEVER data.
Usage: python prepare_production_settings.py /secure/new-directory
Creates new files exclusively with mode 0600. No secrets go to stdout.
"""
import json
import os
from pathlib import Path
import secrets
import sys
from app.runtime import get_runtime_settings
def prepare(destination: Path) -> None:
runtime = get_runtime_settings()
keys = [
'jellyfin_base_url', 'jellyfin_api_key', 'jellyfin_public_url',
'jellyseerr_base_url', 'jellyseerr_api_key',
'sonarr_base_url', 'sonarr_api_key', 'radarr_base_url', 'radarr_api_key',
'prowlarr_base_url', 'prowlarr_api_key', 'bazarr_base_url', 'bazarr_api_key',
'qbittorrent_base_url', 'qbittorrent_username', 'qbittorrent_password',
'magent_notify_enabled', 'magent_notify_email_enabled',
'magent_notify_email_smtp_host', 'magent_notify_email_smtp_port',
'magent_notify_email_smtp_username', 'magent_notify_email_smtp_password',
'magent_notify_email_from_address', 'magent_notify_email_from_name',
'magent_notify_email_use_tls', 'magent_notify_email_use_ssl',
]
values = {key.upper(): getattr(runtime, key) for key in keys if getattr(runtime, key, None) is not None}
password = secrets.token_urlsafe(30)
values.update(
APP_NAME='Magent', JWT_SECRET=secrets.token_urlsafe(48),
ADMIN_USERNAME='admin', ADMIN_PASSWORD=password,
AUTH_COOKIE_SECURE=True, AUTH_COOKIE_DOMAIN='magent.grizzlyflix.co.nz',
AUTH_COOKIE_NAME='magent_auth', AUTH_STATE_COOKIE_NAME='magent_logged_in',
CORS_ALLOW_ORIGIN='https://magent.grizzlyflix.co.nz',
MAGENT_APPLICATION_URL='https://magent.grizzlyflix.co.nz',
MAGENT_API_URL='https://magent.grizzlyflix.co.nz/api',
SQLITE_PATH='/app/data/magent.db', LOG_FILE='/app/data/magent.log',
SITE_BANNER_ENABLED=False, MAGENT_COMING_SOON=True,
BACKGROUND_TASKS_ENABLED=False,
)
destination.mkdir(mode=0o700, parents=True, exist_ok=False)
def write_private(name, content):
with os.fdopen(os.open(destination / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'w') as stream:
stream.write(content)
# Compose single-quoted values preserve dollar signs in SMTP passwords.
def encode(value):
text = str(value).lower() if isinstance(value, bool) else str(value)
if '\n' in text or '\r' in text:
raise ValueError('Multiline configuration values require manual review')
return "'" + text.replace('\\', '\\\\').replace("'", "\\'") + "'"
write_private('.env', ''.join(f'{key}={encode(value)}\n' for key, value in values.items()))
write_private('bootstrap-admin.json', json.dumps({'username': 'admin', 'password': password}))
print(f'Prepared {len(keys)} allowlisted connection settings; fresh session and admin credentials. No client records copied.')
if __name__ == '__main__':
prepare(Path(sys.argv[1]))
+20
View File
@@ -0,0 +1,20 @@
const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const page = await browser.newPage()
await page.route('**/api/**', route => route.fulfill({ json: {} }))
await page.goto('http://127.0.0.1:3101/')
assert.ok(page.url().endsWith('/coming-soon'))
await page.getByRole('heading', { name: 'Your next watch. Made simpler.' }).waitFor()
assert.equal(await page.locator('.header').count(), 0)
assert.equal(await page.getByRole('link', { name: 'Admin sign in' }).getAttribute('href'), '/login')
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 })
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
if (process.env.REVIEW_DIR) await page.screenshot({ path: `${process.env.REVIEW_DIR}/coming-soon-${width}.png`, fullPage: true })
}
console.log('PASS: cover redirect, isolated layout, admin login link and responsive widths')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })