Initial commit

This commit is contained in:
2026-01-22 22:49:57 +13:00
commit fe43a81175
67 changed files with 9408 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
'use client'
import type { ReactNode } from 'react'
import AdminSidebar from './AdminSidebar'
type AdminShellProps = {
title: string
subtitle?: string
actions?: ReactNode
children: ReactNode
}
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) {
return (
<div className="admin-shell">
<aside className="admin-shell-nav">
<AdminSidebar />
</aside>
<main className="card admin-card">
<div className="admin-header">
<div>
<h1>{title}</h1>
{subtitle && <p className="lede">{subtitle}</p>}
</div>
{actions}
</div>
{children}
</main>
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { usePathname } from 'next/navigation'
const NAV_GROUPS = [
{
title: 'Services',
items: [
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr' },
{ href: '/admin/prowlarr', label: 'Prowlarr' },
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
],
},
{
title: 'Requests',
items: [
{ href: '/admin/requests', label: 'Request syncing' },
{ href: '/admin/artwork', label: 'Artwork' },
{ href: '/admin/cache', label: 'Cache' },
],
},
{
title: 'Admin',
items: [
{ href: '/users', label: 'Users' },
{ href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' },
],
},
]
export default function AdminSidebar() {
const pathname = usePathname()
return (
<nav className="admin-sidebar">
<div className="admin-sidebar-title">Settings</div>
{NAV_GROUPS.map((group) => (
<div key={group.title} className="admin-nav-group">
<span className="admin-nav-title">{group.title}</span>
<div className="admin-nav-links">
{group.items.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href))
return (
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
{item.label}
</a>
)
})}
</div>
</div>
))}
</nav>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import { useEffect } from 'react'
import { getApiBase } from '../lib/auth'
const STORAGE_KEY = 'branding_version'
export default function BrandingFavicon() {
useEffect(() => {
const baseUrl = getApiBase()
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
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
}, [])
return null
}

View File

@@ -0,0 +1,36 @@
'use client'
import { useEffect, useState } from 'react'
import { getApiBase } from '../lib/auth'
const STORAGE_KEY = 'branding_version'
type BrandingLogoProps = {
className?: string
alt?: string
}
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 (
<img
className={className}
src={src}
alt={alt}
onError={() => setSrc(null)}
/>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderActions() {
const [signedIn, setSignedIn] = useState(false)
const [role, setRole] = useState<string | null>(null)
useEffect(() => {
const token = getToken()
setSignedIn(Boolean(token))
if (!token) {
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
setSignedIn(false)
setRole(null)
return
}
const data = await response.json()
setRole(data?.role ?? null)
} catch (err) {
console.error(err)
}
}
void load()
}, [])
const signOut = () => {
clearToken()
setSignedIn(false)
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
}
return (
<div className="header-actions">
<a href="/">Requests</a>
<a href="/how-it-works">How it works</a>
{signedIn && <a href="/profile">My profile</a>}
{role === 'admin' && <a href="/admin">Settings</a>}
{signedIn ? (
<button type="button" className="header-link" onClick={signOut}>
Sign out
</button>
) : (
<a href="/login">Sign in</a>
)}
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<string | null>(null)
const [open, setOpen] = useState(false)
useEffect(() => {
const token = getToken()
if (!token) {
setIdentity(null)
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
setIdentity(null)
return
}
const data = await response.json()
if (data?.username) {
setIdentity(`${data.username}${data.role ? ` (${data.role})` : ''}`)
}
} catch (err) {
console.error(err)
setIdentity(null)
}
}
void load()
}, [])
if (!identity) {
return null
}
return (
<div className="signed-in-menu">
<button type="button" className="signed-in" onClick={() => setOpen((prev) => !prev)}>
Signed in as {identity}
</button>
{open && (
<div className="signed-in-dropdown">
<a href="/profile">My profile</a>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { useEffect, useState } from 'react'
const STORAGE_KEY = 'magent_theme'
const getPreferredTheme = () => {
if (typeof window === 'undefined') return 'dark'
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') {
return stored
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const applyTheme = (theme: string) => {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-theme', theme)
}
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
const preferred = getPreferredTheme()
setTheme(preferred)
applyTheme(preferred)
}, [])
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
applyTheme(next)
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, next)
}
}
return (
<button
type="button"
className="theme-toggle"
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3a8.5 8.5 0 1 0 11.5 11.5z" />
</svg>
)}
</button>
)
}