5 Commits
Author SHA1 Message Date
WonderAliceUwU fb07b27675 🔖 chore: release 1.2.3 2026-11-01 12:17:41 +01:00
WonderAliceUwU 9f1d50b35d 🐛 fix: show the wishlist heart on series opened from continue watching, hide card hearts on mobile and revalidate the app shell 2026-11-01 12:17:41 +01:00
WonderAliceUwU 3c7836e18b 🔖 chore: release 1.2.2 2026-10-30 13:01:16 +01:00
WonderAliceUwU 739afbefe3 ⭐️ feat: download a single episode of a series that is not in sonarr yet
- add the series with nothing monitored and search only that episode
- make the wishlist heart, play, delete and request plain icons split by dividers
2026-10-30 13:01:16 +01:00
WonderAliceUwU d7ff401d33 🐛 fix: keep the master password and save jellyfin from onboarding
- only a missing store counts as a fresh install; read errors no longer reopen the password setup
- read aliceflix.secure.json when omniarr.secure.json does not exist yet
- save the settings when loading the jellyfin users in onboarding
- show a short message instead of the proxy's html on 502, 503 and 504
- keep a wishlist per profile and add admin backup export and import
2026-10-30 12:37:22 +01:00
9 changed files with 781 additions and 50 deletions

No files matched your search

+3 -3
View File
@@ -43,7 +43,7 @@ Create a directory for OmniArr and add this `docker-compose.yml`:
```yaml
services:
omniarr:
image: wonderalice/omniarr:1.2.1
image: wonderalice/omniarr:1.2.3
container_name: omniarr
restart: unless-stopped
ports:
@@ -90,11 +90,11 @@ the mount loses that state when the container is replaced.
The current release is available as:
```text
wonderalice/omniarr:1.2.1
wonderalice/omniarr:1.2.3
wonderalice/omniarr:latest
```
Pin `1.2.1` in production for repeatable updates. Use `latest` only when you want
Pin `1.2.3` in production for repeatable updates. Use `latest` only when you want
Compose to follow the newest published image.
## Build locally
+1 -1
View File
@@ -1,6 +1,6 @@
services:
omniarr:
image: wonderalice/omniarr:1.2.1
image: wonderalice/omniarr:1.2.3
# Para construir desde código fuente en su lugar, descomenta estas líneas:
# build:
# context: .
+167 -9
View File
@@ -6,7 +6,12 @@ import {
defaultConfig,
type AppConfig,
type AuthPayload,
type BackupFile,
type MediaCard,
type WishlistEntry,
type WishlistTogglePayload,
type EpisodeRequestPayload,
type SeriesEpisodeRequestPayload,
type EpisodeWatchStateRequest,
type DiskSpaceStatus,
type HubPageResponse,
@@ -17,7 +22,18 @@ import {
type TitleDetailsRequest
} from '../shared/contracts.js';
import { decryptJson, deriveEncryptionKey, encryptJson, randomToken, verifyPassword, createPasswordRecord } from './lib/security.js';
import { loadState, migrateConfig, readAdmin, readEncryptedConfig, saveAdminAndConfig, updateEncryptedConfig } from './lib/store.js';
import {
loadState,
migrateConfig,
readAdmin,
readAllWishlists,
readEncryptedConfig,
readWishlist,
restoreBackup,
saveAdminAndConfig,
updateEncryptedConfig,
updateWishlist
} from './lib/store.js';
import {
getCalendar,
getConnectionStatuses,
@@ -35,6 +51,7 @@ import {
proxyJellyfinImage,
removeDownload,
requestEpisode,
requestSeriesEpisode,
requestMovie,
requestSeason,
requestSeries,
@@ -252,6 +269,72 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
return;
}
if (url.pathname.startsWith('/api/admin/') && session?.role !== 'admin') {
sendJson(response, 403, { error: 'Administrator access is required.' });
return;
}
if (url.pathname === '/api/admin/backup' && method === 'GET') {
const backup: BackupFile = {
app: 'omniarr',
format: 1,
exportedAt: new Date().toISOString(),
config: await readConfig(),
wishlists: await readAllWishlists()
};
const fileName = `omniarr-backup-${backup.exportedAt.slice(0, 10)}.json`;
response.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${fileName}"`,
'Cache-Control': 'no-store'
});
response.end(JSON.stringify(backup, null, 2));
return;
}
if (url.pathname === '/api/admin/backup' && method === 'POST') {
let body: Partial<BackupFile>;
let config: AppConfig;
try {
body = await readJson<Partial<BackupFile>>(request);
if (body.app !== 'omniarr' || body.format !== 1 || !body.config || typeof body.config !== 'object') {
throw new Error('invalid');
}
config = mergeStoredConfig(body.config);
} catch {
sendJson(response, 400, { error: 'This file is not an OmniArr backup.' });
return;
}
config = { ...config, jellyfin: { ...config.jellyfin, userId: '' } };
await restoreBackup(
encryptJson(config, await getConfigEncryptionKey()),
sanitizeWishlists(body.wishlists)
);
sendJson(response, 200, { config, connections: await getConnectionStatuses(config) });
return;
}
if (url.pathname === '/api/wishlist' && method === 'GET') {
sendJson(response, 200, { items: await readWishlist(getWishlistOwner(session!)) });
return;
}
if (url.pathname === '/api/wishlist' && method === 'POST') {
const body = await readJson<WishlistTogglePayload>(request);
const card = sanitizeWishlistCard(body.card);
if (!card) {
sendJson(response, 400, { error: 'A title is required.' });
return;
}
const key = getWishlistKey(card);
const items = await updateWishlist(getWishlistOwner(session!), (entries) => {
const remaining = entries.filter((entry) => getWishlistKey(entry.card) !== key);
return body.wishlisted ? [{ card, addedAt: new Date().toISOString() }, ...remaining] : remaining;
});
sendJson(response, 200, { items });
return;
}
if (url.pathname === '/api/settings' && method === 'GET') {
const config = await readConfig(session!.encryptionKey);
sendJson(response, 200, session!.role === 'admin' ? config : redactConfig(config));
@@ -426,6 +509,14 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
return;
}
if (url.pathname === '/api/requests/series-episode' && method === 'POST') {
const config = await readConfig(session!.encryptionKey);
const body = await readJson<SeriesEpisodeRequestPayload & { user: string | null }>(request);
await requestSeriesEpisode(config, body, body.user);
sendJson(response, 200, { ok: true });
return;
}
if (url.pathname === '/api/seasons/delete' && method === 'POST') {
const config = await readConfig(session!.encryptionKey);
const body = await readJson<SeasonDeletePayload>(request);
@@ -580,9 +671,17 @@ async function readConfig(legacyEncryptionKey?: Buffer): Promise<AppConfig> {
throw new Error('The configuration must be unlocked by an administrator.');
}
const stored = decryptJson<Partial<AppConfig>>(encrypted, encryptionKey);
const config = mergeStoredConfig(decryptJson<Partial<AppConfig>>(encrypted, encryptionKey));
// The Jellyfin user always comes from the profile the session signed in
// with; admin sessions have none, so a userId saved by older versions is ignored.
const userId = legacyEncryptionKey ? sessionUsers.get(legacyEncryptionKey) : undefined;
return { ...config, jellyfin: { ...config.jellyfin, userId: userId || '' } };
}
const config = sanitizeConfig({
// Fills in whatever a stored config (or an imported backup) leaves out with
// the defaults, so configs saved by older versions keep loading.
function mergeStoredConfig(stored: Partial<AppConfig>): AppConfig {
return sanitizeConfig({
preferences: {
...defaultConfig.preferences,
...(stored.preferences || {})
@@ -608,10 +707,66 @@ async function readConfig(legacyEncryptionKey?: Buffer): Promise<AppConfig> {
...(stored.tvdb || {})
}
});
// The Jellyfin user always comes from the profile the session signed in
// with; admin sessions have none, so a userId saved by older versions is ignored.
const userId = legacyEncryptionKey ? sessionUsers.get(legacyEncryptionKey) : undefined;
return { ...config, jellyfin: { ...config.jellyfin, userId: userId || '' } };
}
// Profiles keep their own wishlist; the administrator has no Jellyfin profile
// and shares a single one.
function getWishlistOwner(session: SessionRecord): string {
return session.role === 'user' && session.userId ? session.userId : 'admin';
}
// The same title can reach the client as a Jellyfin card or a TMDb card, so
// titles are matched by their TMDb id when they have one.
function getWishlistKey(card: MediaCard): string {
return card.tmdbId ? `${card.mediaType === 'movie' ? 'movie' : 'tv'}:${card.tmdbId}` : card.id;
}
const WISHLIST_MEDIA_TYPES = new Set<MediaCard['mediaType']>(['movie', 'tv', 'series']);
// Only what the wishlist page needs to draw a tile and open the title is kept;
// progress and labels belong to the moment the card was saved.
function sanitizeWishlistCard(input: unknown): MediaCard | null {
if (!input || typeof input !== 'object') {
return null;
}
const card = input as Partial<MediaCard>;
const text = (value: unknown) => (typeof value === 'string' ? value : '');
const id = text(card.id);
if (!id || !card.mediaType || !WISHLIST_MEDIA_TYPES.has(card.mediaType)) {
return null;
}
return {
id,
tmdbId: Number(card.tmdbId) || undefined,
providerId: text(card.providerId) || undefined,
title: text(card.title),
subtitle: text(card.subtitle),
overview: text(card.overview),
mediaType: card.mediaType,
posterUrl: text(card.posterUrl),
backdropUrl: text(card.backdropUrl),
logoUrl: text(card.logoUrl) || undefined,
backdropLanguageFallback: card.backdropLanguageFallback ? true : undefined,
releaseDate: text(card.releaseDate) || undefined,
firstAirDate: text(card.firstAirDate) || undefined
};
}
function sanitizeWishlists(input: unknown): Record<string, WishlistEntry[]> {
const wishlists: Record<string, WishlistEntry[]> = {};
if (!input || typeof input !== 'object') {
return wishlists;
}
for (const [owner, entries] of Object.entries(input)) {
if (!Array.isArray(entries)) {
continue;
}
wishlists[owner] = entries.flatMap((entry: Partial<WishlistEntry>) => {
const card = sanitizeWishlistCard(entry?.card);
return card ? [{ card, addedAt: typeof entry.addedAt === 'string' ? entry.addedAt : new Date().toISOString() }] : [];
});
}
return wishlists;
}
async function getConfigEncryptionKey(): Promise<Buffer> {
@@ -662,14 +817,17 @@ async function serveApp(pathname: string, response: ServerResponse): Promise<voi
try {
const content = await readFile(filePath);
response.writeHead(200, {
'Content-Type': contentType(filePath)
'Content-Type': contentType(filePath),
// Hashed bundles never change; the entry page must always be revalidated so a new release reaches every browser.
'Cache-Control': relativePath.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'
});
response.end(content);
} catch {
try {
const indexHtml = await readFile(resolve(process.cwd(), 'dist', 'index.html'));
response.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8'
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache'
});
response.end(indexHtml);
} catch {
+74 -1
View File
@@ -17,6 +17,7 @@ import type {
ProcessingItem,
SeasonDeletePayload,
SeasonRequestPayload,
SeriesEpisodeRequestPayload,
SelectOption,
ServiceOptionsResponse,
TitleDetailsEpisode,
@@ -2442,7 +2443,11 @@ async function buildSeasonEpisodes(
availability,
downloadStatus,
downloadProgress: isDownloading ? getEpisodeQueueProgress(queueEntry) : undefined,
canDownload: isManagedInSonarr && availability === 'missing',
// A series that is not in Sonarr yet is added by the request itself,
// unless Jellyfin already has the episode.
canDownload: Boolean(config.sonarr.baseUrl && config.sonarr.apiKey)
&& (isManagedInSonarr || (Boolean(tmdbId) && !jellyfinKnowsAboutIt))
&& availability === 'missing',
canDelete: hasFile,
watched: jellyfinEpisode?.watched,
progress: jellyfinEpisode?.progress,
@@ -2973,6 +2978,67 @@ export async function requestEpisode(config: AppConfig, payload: EpisodeRequestP
});
}
export async function requestSeriesEpisode(config: AppConfig, payload: SeriesEpisodeRequestPayload, user: string | null): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
}
const headers = { 'X-Api-Key': config.sonarr.apiKey };
const details = await tmdbFetch(
config,
`/tv/${payload.tmdbId}?append_to_response=external_ids&language=${encodeURIComponent(config.preferences.language)}`
);
if (!details) {
throw new Error('Failed to load the series from TMDb.');
}
const tvdbId = Number(asRecord(details?.external_ids).tvdb_id || 0);
if (!tvdbId) {
throw new Error('This series does not have a TVDB ID for Sonarr.');
}
// Nothing is monitored on add, so Sonarr searches for nothing but the
// episode that is monitored and searched below.
const sonarrRequestConfig = await resolveSonarrRequestConfig(config);
const lookup = await fetchJson<SonarrSeriesRecord[]>(
`${config.sonarr.baseUrl}/api/v3/series/lookup?term=${encodeURIComponent(`tvdb:${tvdbId}`)}`,
{ headers }
);
const series = await fetchJson<SonarrSeriesRecord>(`${config.sonarr.baseUrl}/api/v3/series`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: details.name || details.original_name || 'Series',
tvdbId,
qualityProfileId: payload.qualityProfileId || sonarrRequestConfig.qualityProfileId,
languageProfileId: sonarrRequestConfig.languageProfileId,
rootFolderPath: sonarrRequestConfig.rootFolderPath,
seasonFolder: true,
monitored: true,
seasons: (lookup[0]?.seasons || []).map((season) => ({ seasonNumber: season.seasonNumber, monitored: false })),
addOptions: { monitor: 'none', searchForMissingEpisodes: false, searchForCutoffUnmetEpisodes: false }
})
});
if (!series.id) {
throw new Error('Sonarr did not return the added series.');
}
// Sonarr lists the episodes only after its first scan of the new series.
for (let attempt = 0; attempt < 20; attempt += 1) {
const episodes = await fetchJson<Array<{ id?: number; seasonNumber?: number; episodeNumber?: number }>>(
`${config.sonarr.baseUrl}/api/v3/episode?seriesId=${series.id}`,
{ headers }
);
const episode = episodes.find((entry) => entry.seasonNumber === payload.seasonNumber && entry.episodeNumber === payload.episodeNumber);
if (episode?.id) {
await requestEpisode(config, { episodeId: episode.id, qualityProfileId: payload.qualityProfileId }, user);
return;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error('Sonarr added the series but has not listed its episodes yet. Try the episode again in a moment.');
}
export async function deleteSeasonFiles(config: AppConfig, payload: SeasonDeletePayload): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
@@ -4048,6 +4114,13 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
}
}
// Proxies answer with a whole HTML page; it says nothing the status does not.
if (/^\s*<(!doctype|html)/i.test(detail)) detail = '';
if ([502, 503, 504].includes(response.status)) {
throw new Error(`${response.status} ${response.statusText}: the service did not answer. Check that it is running and that the URL points to it.`);
}
throw new Error(detail
? `${response.status} ${response.statusText}: ${detail}`
: `${response.status} ${response.statusText}`);
+82 -21
View File
@@ -1,5 +1,7 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import type { WishlistEntry } from '../../shared/contracts.js';
interface StoredAdmin {
passwordHash: string;
passwordSalt: string;
@@ -17,37 +19,75 @@ interface StoredState {
admin: StoredAdmin | null;
config: EncryptedConfig | null;
configKey: string | null;
/** Each profile's wishlist, keyed by its Jellyfin user id ('admin' for the administrator). */
wishlists: Record<string, WishlistEntry[]>;
}
const DATA_DIR = resolve(process.env.OMNIARR_DATA_DIR || resolve(process.cwd(), 'data'));
const STORE_FILE = resolve(DATA_DIR, 'omniarr.secure.json');
// The store used to be called aliceflix.secure.json; it is still read when the
// new file does not exist yet, so a rename never looks like a fresh install.
const LEGACY_STORE_FILE = resolve(DATA_DIR, 'aliceflix.secure.json');
const EMPTY_STATE: StoredState = {
version: 1,
admin: null,
config: null,
configKey: null
configKey: null,
wishlists: {}
};
export async function loadState(): Promise<StoredState> {
try {
const raw = await readFile(STORE_FILE, 'utf8');
const parsed = JSON.parse(raw) as Partial<StoredState>;
// Every write reads the file, changes it and writes it back, so writes are
// queued one after another; two profiles saving at once would otherwise drop
// one of the changes.
let writeQueue: Promise<unknown> = Promise.resolve();
return {
version: parsed.configKey ? 2 : 1,
admin: parsed.admin ?? null,
config: parsed.config ?? null,
configKey: typeof parsed.configKey === 'string' ? parsed.configKey : null
};
} catch {
return EMPTY_STATE;
async function readStoreFile(): Promise<string | null> {
for (const file of [STORE_FILE, LEGACY_STORE_FILE]) {
try {
return await readFile(file, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
return null;
}
// Only a store that does not exist counts as a fresh install. Any other read or
// parse failure is thrown: treating it as empty would reopen the master
// password setup and let the next visitor overwrite the real one.
export async function loadState(): Promise<StoredState> {
const raw = await readStoreFile();
if (raw === null) return EMPTY_STATE;
const parsed = JSON.parse(raw) as Partial<StoredState>;
return {
version: parsed.configKey ? 2 : 1,
admin: parsed.admin ?? null,
config: parsed.config ?? null,
configKey: typeof parsed.configKey === 'string' ? parsed.configKey : null,
wishlists: parsed.wishlists && typeof parsed.wishlists === 'object' ? parsed.wishlists : {}
};
}
export async function saveState(state: StoredState): Promise<void> {
await mkdir(dirname(STORE_FILE), { recursive: true });
await writeFile(STORE_FILE, JSON.stringify(state, null, 2), 'utf8');
// Written to a temporary file first, so a crash mid-write never leaves a
// half-written store behind.
const temporaryFile = `${STORE_FILE}.tmp`;
await writeFile(temporaryFile, JSON.stringify(state, null, 2), 'utf8');
await rename(temporaryFile, STORE_FILE);
}
export function updateState(change: (state: StoredState) => StoredState): Promise<StoredState> {
const next = writeQueue.then(async () => {
const updated = change(await loadState());
await saveState(updated);
return updated;
});
writeQueue = next.catch(() => undefined);
return next;
}
export async function readEncryptedConfig(): Promise<EncryptedConfig | null> {
@@ -60,20 +100,20 @@ export async function saveAdminAndConfig(
config: EncryptedConfig,
configKey: string
): Promise<void> {
await saveState({
await updateState((state) => ({
...state,
version: 2,
admin,
config,
configKey
});
}));
}
export async function updateEncryptedConfig(config: EncryptedConfig): Promise<void> {
const state = await loadState();
await saveState({
await updateState((state) => ({
...state,
config
});
}));
}
export async function readAdmin(): Promise<StoredAdmin | null> {
@@ -82,8 +122,29 @@ export async function readAdmin(): Promise<StoredAdmin | null> {
}
export async function migrateConfig(config: EncryptedConfig, configKey: string): Promise<void> {
await updateState((state) => ({ ...state, version: 2, config, configKey }));
}
export async function readWishlist(owner: string): Promise<WishlistEntry[]> {
const state = await loadState();
await saveState({ ...state, version: 2, config, configKey });
return state.wishlists[owner] ?? [];
}
export async function updateWishlist(owner: string, change: (entries: WishlistEntry[]) => WishlistEntry[]): Promise<WishlistEntry[]> {
const state = await updateState((current) => ({
...current,
wishlists: { ...current.wishlists, [owner]: change(current.wishlists[owner] ?? []) }
}));
return state.wishlists[owner];
}
export async function readAllWishlists(): Promise<Record<string, WishlistEntry[]>> {
const state = await loadState();
return state.wishlists;
}
export async function restoreBackup(config: EncryptedConfig, wishlists: Record<string, WishlistEntry[]>): Promise<void> {
await updateState((state) => ({ ...state, config, wishlists }));
}
export type { EncryptedConfig, StoredAdmin, StoredState };
+33
View File
@@ -12,6 +12,7 @@ export type AppRoute =
| 'library'
| 'calendar'
| 'downloads'
| 'wishlist'
| 'settings';
export interface ServiceConfig {
@@ -303,6 +304,15 @@ export interface EpisodeRequestPayload {
qualityProfileId?: number;
}
// An episode of a series that is not in Sonarr yet: it is added with nothing
// monitored but this episode.
export interface SeriesEpisodeRequestPayload {
tmdbId: number;
seasonNumber: number;
episodeNumber: number;
qualityProfileId?: number;
}
export interface SeasonDeletePayload {
seriesId: number;
seasonNumber: number;
@@ -347,6 +357,29 @@ export interface AuthResponse {
message?: string;
}
export interface WishlistEntry {
card: MediaCard;
addedAt: string;
}
export interface WishlistResponse {
items: WishlistEntry[];
}
export interface WishlistTogglePayload {
card: MediaCard;
wishlisted: boolean;
}
/** What the administration exports: the service settings and every profile's wishlist. */
export interface BackupFile {
app: 'omniarr';
format: 1;
exportedAt: string;
config: AppConfig;
wishlists: Record<string, WishlistEntry[]>;
}
export interface ApiErrorPayload {
error: string;
}
+276 -14
View File
@@ -3,8 +3,10 @@ import {
type FormEvent,
type MouseEvent,
type ReactNode,
createContext,
startTransition,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
@@ -29,6 +31,7 @@ import {
type TitleDetailsCastMember,
type TitleDetailsEpisode,
type TitleDetailsResponse,
type WishlistEntry,
} from '../shared/contracts'
type AuthState = 'loading' | 'setup' | 'login' | 'profiles' | 'admin' | 'app'
@@ -42,12 +45,13 @@ const EPISODE_SCROLL_DISTANCE_RATIO = 0.72
const SEASON_DOMINANCE_SWITCH_RATIO = 0.12
const SEASON_DOMINANCE_SWITCH_MIN_PX = 56
type SidebarItem = { route: AppRoute; label: string; short: string; icon?: 'calendar' | 'search' | 'downloads' | 'home' | 'library'; iconClassName?: string }
type SidebarItem = { route: AppRoute; label: string; short: string; icon?: 'calendar' | 'search' | 'downloads' | 'home' | 'library' | 'wishlist'; iconClassName?: string }
const topSidebarItems: SidebarItem[] = [
{ route: 'search', label: 'Search', short: 'S', icon: 'search', iconClassName: 'sidebar-search-icon' },
{ route: 'calendar', label: 'Upcoming', short: 'C', icon: 'calendar' },
{ route: 'downloads', label: 'Downloads', short: 'D', icon: 'downloads' },
{ route: 'wishlist', label: 'Wishlist', short: 'W', icon: 'wishlist' },
]
const sidebarItems: SidebarItem[] = [
@@ -65,6 +69,27 @@ const networkSidebarItems: Array<{ route: AppRoute; label: string; icon: string;
{ route: 'disney', label: 'Disney+', icon: '/networks/disney.webp' },
]
// Routes that render their own page instead of a hub fetched from the server.
const NON_HUB_ROUTES = new Set<AppRoute>(['downloads', 'settings', 'search', 'calendar', 'wishlist'])
function isHubRoute(route: AppRoute) {
return !NON_HUB_ROUTES.has(route)
}
// Lets every tile and the title modal show and change whether a title is on
// the current profile's wishlist without passing it through each component.
const WishlistContext = createContext<{ isWishlisted: (card: MediaCard) => boolean; toggle: (card: MediaCard) => void } | null>(null)
// The same title can come from Jellyfin or TMDb, so it is matched by its TMDb
// id when it has one; the server matches titles the same way.
function getWishlistKey(card: MediaCard) {
return card.tmdbId ? `${card.mediaType === 'movie' ? 'movie' : 'tv'}:${card.tmdbId}` : card.id
}
function canWishlist(card: MediaCard) {
return card.mediaType === 'movie' || card.mediaType === 'tv' || card.mediaType === 'series'
}
function App() {
const [authState, setAuthState] = useState<AuthState>('loading')
const [sessionRole, setSessionRole] = useState<'admin' | 'user' | null>(null)
@@ -98,6 +123,7 @@ function App() {
const [lastSearchQuery, setLastSearchQuery] = useState('')
const [selectedCard, setSelectedCard] = useState<SelectedCardState | null>(null)
const [downloadDeleteId, setDownloadDeleteId] = useState('')
const [wishlist, setWishlist] = useState<WishlistEntry[]>([])
const [entryTransitionState, setEntryTransitionState] = useState<EntryTransitionState>('idle')
async function transitionEntryStage(nextStage: EntryStage) {
@@ -123,13 +149,15 @@ function App() {
}
async function refreshBaseData() {
const [settings, serviceOptions, dashboard, storage] = await Promise.all([
const [settings, serviceOptions, dashboard, storage, savedWishlist] = await Promise.all([
api.getSettings(),
api.getOptions(),
api.getDashboard(),
api.getDiskSpace(),
api.getWishlist(),
])
setWishlist(savedWishlist.items)
setConfig(settings)
setOptions(serviceOptions)
setDownloads(dashboard.downloads)
@@ -209,7 +237,7 @@ function App() {
return
}
if (route === 'settings' || route === 'calendar' || route === 'search') {
if (route === 'settings' || route === 'calendar' || route === 'search' || route === 'wishlist') {
return
}
@@ -253,7 +281,7 @@ function App() {
const interval = window.setInterval(() => {
void refreshBaseData()
if (route !== 'downloads' && route !== 'settings' && route !== 'search' && route !== 'calendar') {
if (isHubRoute(route)) {
void loadHub(route as HubPageResponse['page'], { silent: true })
}
}, 30000)
@@ -314,6 +342,13 @@ function App() {
apiKey: config.jellyfin.apiKey,
})
setOptions((current) => ({ ...current, jellyfinUsers: response.jellyfinUsers }))
// Loading the users only tests the typed values; save them so the
// connection stays configured after this screen.
if (sessionRole === 'admin') {
const saved = await api.saveSettings(config)
setConfig(saved.config)
setConnections(saved.connections)
}
await transitionEntryStage(response.jellyfinUsers.length ? 'profiles' : 'onboarding')
setNotice(response.jellyfinUsers.length ? 'Jellyfin users loaded.' : 'No Jellyfin users found.')
} catch (loadError) {
@@ -375,7 +410,7 @@ function App() {
} else {
await api.requestSeries(card.tmdbId, currentJellyfinUser, qualityProfileId, seasonNumber)
}
if (route !== 'downloads' && route !== 'settings' && route !== 'search' && route !== 'calendar') {
if (isHubRoute(route)) {
await loadHub(route as HubPageResponse['page'], { silent: true })
}
setNotice(`${card.title} sent to ${card.mediaType === 'movie' ? 'Radarr' : 'Sonarr'}.`)
@@ -441,6 +476,66 @@ function App() {
}
}
async function handleToggleWishlist(card: MediaCard) {
const key = getWishlistKey(card)
const wishlisted = !wishlist.some((entry) => getWishlistKey(entry.card) === key)
const previous = wishlist
// The heart fills straight away; the server's answer then replaces the list.
setWishlist(wishlisted
? [{ card, addedAt: new Date().toISOString() }, ...previous]
: previous.filter((entry) => getWishlistKey(entry.card) !== key))
try {
const response = await api.setWishlisted({ card, wishlisted })
setWishlist(response.items)
} catch (wishlistError) {
setWishlist(previous)
setError(wishlistError instanceof Error ? wishlistError.message : 'Failed to update the wishlist.')
}
}
async function handleImportBackup(file: File) {
setBusy(true)
setError('')
setNotice('')
try {
let backup: unknown
try {
backup = JSON.parse(await file.text())
} catch {
throw new Error('This file is not an OmniArr backup.')
}
const response = await api.importBackup(backup)
setConfig(response.config)
setConnections(response.connections)
await refreshBaseData()
setNotice('Backup restored.')
} catch (importError) {
setError(importError instanceof Error ? importError.message : 'Failed to restore the backup.')
} finally {
setBusy(false)
}
}
async function handleExportBackup() {
setError('')
try {
const backup = await api.exportBackup()
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }))
const link = document.createElement('a')
link.href = url
link.download = `omniarr-backup-${backup.exportedAt.slice(0, 10)}.json`
link.click()
URL.revokeObjectURL(url)
} catch (exportError) {
setError(exportError instanceof Error ? exportError.message : 'Failed to export the backup.')
}
}
const wishlistContext = {
isWishlisted: (card: MediaCard) => wishlist.some((entry) => getWishlistKey(entry.card) === getWishlistKey(card)),
toggle: (card: MediaCard) => void handleToggleWishlist(card),
}
function updateConfig<K extends keyof AppConfig>(key: K, value: AppConfig[K]) {
setConfig((current) => ({
...current,
@@ -514,8 +609,12 @@ function App() {
busy={busy}
config={config}
connections={connections}
error={error}
notice={notice}
onExit={() => setAuthState('profiles')}
onCheck={saveSettings}
onExportBackup={handleExportBackup}
onImportBackup={handleImportBackup}
onSave={handleSaveSettings}
options={options}
updateConfig={updateConfig}
@@ -564,9 +663,10 @@ function App() {
const currentJellyfinUser = sessionRole === 'admin'
? 'Administration'
: options.jellyfinUsers.find((option) => option.id === config.jellyfin.userId)?.label || 'Media cockpit'
const currentHub = route !== 'downloads' && route !== 'settings' && route !== 'calendar' && route !== 'search' ? hubPages[route as HubPageResponse['page']] : undefined
const currentHub = isHubRoute(route) ? hubPages[route as HubPageResponse['page']] : undefined
return (
<WishlistContext.Provider value={wishlistContext}>
<div className={`omniarr-app ${getEntryTransitionClassName(entryTransitionState)}`}>
<Sidebar
busy={busy}
@@ -612,12 +712,20 @@ function App() {
config={config}
connections={connections}
onCheck={saveSettings}
onExportBackup={handleExportBackup}
onImportBackup={handleImportBackup}
onSave={handleSaveSettings}
options={options}
updateConfig={updateConfig}
/>
) : null}
{route !== 'downloads' && route !== 'settings' && route !== 'calendar' && route !== 'search' ? (
{route === 'wishlist' ? (
<WishlistPage
items={wishlist}
onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })}
/>
) : null}
{isHubRoute(route) ? (
<HubPageView
busy={busy}
hub={currentHub}
@@ -647,6 +755,7 @@ function App() {
/>
) : null}
</div>
</WishlistContext.Provider>
)
}
@@ -734,7 +843,7 @@ function Sidebar(props: {
type="button"
>
<span className={item.iconClassName ? `sidebar-link-short ${item.iconClassName}` : 'sidebar-link-short'} aria-hidden="true">
{item.icon === 'calendar' ? <CalendarGlyph /> : item.icon === 'search' ? <SearchGlyph /> : item.icon === 'downloads' ? <DownloadGlyph /> : item.icon === 'home' ? <HomeGlyph /> : item.icon === 'library' ? <LibraryGlyph /> : item.short}
{item.icon === 'calendar' ? <CalendarGlyph /> : item.icon === 'search' ? <SearchGlyph /> : item.icon === 'downloads' ? <DownloadGlyph /> : item.icon === 'home' ? <HomeGlyph /> : item.icon === 'library' ? <LibraryGlyph /> : item.icon === 'wishlist' ? <HeartGlyph /> : item.short}
</span>
<span>{item.label}</span>
</button>
@@ -750,7 +859,7 @@ function Sidebar(props: {
type="button"
>
<span className={item.iconClassName ? `sidebar-link-short ${item.iconClassName}` : 'sidebar-link-short'} aria-hidden="true">
{item.icon === 'calendar' ? <CalendarGlyph /> : item.icon === 'search' ? <SearchGlyph /> : item.icon === 'downloads' ? <DownloadGlyph /> : item.icon === 'home' ? <HomeGlyph /> : item.icon === 'library' ? <LibraryGlyph /> : item.short}
{item.icon === 'calendar' ? <CalendarGlyph /> : item.icon === 'search' ? <SearchGlyph /> : item.icon === 'downloads' ? <DownloadGlyph /> : item.icon === 'home' ? <HomeGlyph /> : item.icon === 'library' ? <LibraryGlyph /> : item.icon === 'wishlist' ? <HeartGlyph /> : item.short}
</span>
<span>{item.label}</span>
</button>
@@ -1679,6 +1788,7 @@ function MediaTile(props: {
{props.card.hoverLabel && !continueEpisodeMeta ? <div className="media-hover-label">{props.card.hoverLabel}</div> : null}
{props.card.progress ? <div className="tile-progress"><div className="tile-progress-fill" style={{ width: `${props.card.progress}%` }} /></div> : null}
</button>
{!props.isContinueWatching && (props.styleType === 'landscape' || props.styleType === 'poster') ? <WishlistHeart card={props.card} className="media-wishlist-button" /> : null}
</article>
)
}
@@ -1998,6 +2108,26 @@ function TitleDetailsModal(props: {
const backgroundImage = details?.backdropUrl || props.card.backdropUrl || props.card.posterUrl
const logoUrl = details?.logoUrl || props.card.logoUrl
const displayTitle = details?.title || props.card.title
// Titles opened from Continue Watching arrive as a Jellyfin episode card, which
// cannot be wishlisted; the series the details resolved to can.
const wishlistCard: MediaCard = props.card.mediaType === 'episode' && details?.mediaType === 'series'
? {
...props.card,
id: details.providerId ? `jellyfin-${details.providerId}` : details.id,
tmdbId: details.tmdbId ?? props.card.tmdbId,
providerId: details.providerId ?? props.card.providerId,
title: details.title,
subtitle: '',
overview: details.overview,
mediaType: 'series',
posterUrl: details.posterUrl,
backdropUrl: details.backdropUrl,
logoUrl: details.logoUrl ?? props.card.logoUrl,
progress: undefined,
secondaryValue: undefined,
hoverLabel: undefined
}
: props.card
const displaySubtitle = details?.subtitle || props.card.subtitle
const displayOverview = details?.overview || props.card.overview || 'No details available yet.'
const displayYear = props.card.releaseDate?.slice(0, 4)
@@ -2449,7 +2579,11 @@ function TitleDetailsModal(props: {
event.preventDefault()
event.stopPropagation()
if (!sonarrEpisodeId || availability === 'downloading' || episodeDownloadBusyId === episodeId) {
if (availability === 'downloading' || episodeDownloadBusyId === episodeId) {
return
}
// Without a Sonarr id the series is not in Sonarr yet; it is added by the request.
if (!sonarrEpisodeId && !props.card.tmdbId) {
return
}
@@ -2462,7 +2596,7 @@ function TitleDetailsModal(props: {
function queueEpisodeDownload(
episodeId: string,
sonarrEpisodeId: number,
sonarrEpisodeId: number | undefined,
availability: TitleDetailsResponse['episodes'][number]['availability'],
qualityProfileId?: number
) {
@@ -2475,7 +2609,17 @@ function TitleDetailsModal(props: {
canDownload: false,
}))
void api.requestEpisode({ episodeId: sonarrEpisodeId, qualityProfileId }, props.currentUser)
const episode = details?.episodes.find((entry) => entry.id === episodeId)
const request = sonarrEpisodeId
? api.requestEpisode({ episodeId: sonarrEpisodeId, qualityProfileId }, props.currentUser)
: api.requestSeriesEpisode({
tmdbId: props.card.tmdbId!,
seasonNumber: episode?.seasonNumber ?? 0,
episodeNumber: episode?.episodeNumber ?? 0,
qualityProfileId,
}, props.currentUser)
void request
.then(() => refreshSeasonState(seasonValue || undefined))
.catch(() => {
setPendingEpisodeRequestIds((current) => current.filter((id) => id !== episodeId))
updateEpisodeInCaches(episodeId, (episode) => ({
@@ -2591,12 +2735,16 @@ function TitleDetailsModal(props: {
) : null}
</p>
<div className="title-details-summary-header">
{details?.mediaType !== 'series' ? (
<WishlistHeart card={wishlistCard} className="title-season-action title-wishlist-button" divider="after" />
) : null}
{canPlay && details?.mediaType !== 'series' ? (
<ActionButton className="title-summary-header-play" onClick={() => setPlayerTarget({ id: playId!, title: playLabel })} variant="play">
<PlayGlyph />
<span>Play</span>
</ActionButton>
) : null}
{canDeleteMovieAction ? <div className="title-season-divider title-season-action-divider" aria-hidden="true" /> : null}
{canDeleteMovieAction ? (
<ActionButton
aria-label="Delete movie files"
@@ -2708,8 +2856,9 @@ function TitleDetailsModal(props: {
<strong>{activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length}</strong>
<span>{(activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length) === 1 ? 'episode' : 'episodes'}</span>
</div>
{shouldShowDownloadSeasonAction || canDeleteSeasonAction ? (
<>
<div className="title-season-actions">
<WishlistHeart card={wishlistCard} className="title-season-action title-wishlist-button" divider="before" />
{shouldShowDownloadSeasonAction || activeSeasonMeta?.isDownloading ? <div className="title-season-divider title-season-action-divider" aria-hidden="true" /> : null}
{shouldShowDownloadSeasonAction || activeSeasonMeta?.isDownloading ? (
activeSeasonDownloadStatus === 'downloading' ? (
@@ -2775,7 +2924,7 @@ function TitleDetailsModal(props: {
</>
) : null}
</div>
) : null}
</>
<div className="title-season-strip-spacer" />
{canPlay ? (
<ActionButton
@@ -3052,6 +3201,79 @@ function PlayGlyph() {
)
}
function HeartGlyph({ filled, className = 'sidebar-glyph-icon' }: { filled?: boolean; className?: string }) {
return (
<svg aria-hidden="true" className={className} fill={filled ? 'currentColor' : 'none'} viewBox="0 0 24 24">
<path d="M12 20.3 4.6 13a4.8 4.8 0 0 1 6.8-6.8l.6.6.6-.6a4.8 4.8 0 0 1 6.8 6.8Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
</svg>
)
}
function WishlistHeart({ card, className, divider }: { card: MediaCard; className: string; divider?: 'before' | 'after' }) {
const wishlist = useContext(WishlistContext)
if (!wishlist || !canWishlist(card)) {
return null
}
const wishlisted = wishlist.isWishlisted(card)
const separator = <div className="title-season-divider title-season-action-divider" aria-hidden="true" />
return (
<>
{divider === 'before' ? separator : null}
<button
aria-label={wishlisted ? `Remove ${card.title} from wishlist` : `Add ${card.title} to wishlist`}
aria-pressed={wishlisted}
className={wishlisted ? `${className} is-wishlisted` : className}
onClick={(event) => {
event.stopPropagation()
wishlist.toggle(card)
}}
title={wishlisted ? 'Remove from wishlist' : 'Add to wishlist'}
type="button"
>
<HeartGlyph className="title-action-icon" filled={wishlisted} />
</button>
{divider === 'after' ? separator : null}
</>
)
}
function WishlistPage(props: {
items: WishlistEntry[]
onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void
}) {
return (
<div className="search-page wishlist-page">
<section className="search-header">
<h2><HeartGlyph /> <span>Wishlist</span></h2>
</section>
{props.items.length ? (
<section className="search-results-section">
<div className="search-results-grid">
{props.items.map((entry) => (
<MediaTile
busy={false}
key={getWishlistKey(entry.card)}
card={entry.card}
isContinueWatching={false}
onOpenCard={props.onOpenCard}
onRequest={async () => {}}
styleType="landscape"
/>
))}
</div>
</section>
) : (
<EmptyState
copy="Tap the heart on any movie or series to save it here."
title="Your wishlist is empty"
/>
)}
</div>
)
}
function CalendarGlyph() {
return (
<svg aria-hidden="true" className="sidebar-glyph-icon" viewBox="0 0 16 16">
@@ -3948,7 +4170,11 @@ function GlobeGlyph() {
function AdministrationScreen(props: {
config: AppConfig
connections: ConnectionStatus[]
error: string
notice: string
onExit: () => void
onExportBackup: () => Promise<void>
onImportBackup: (file: File) => Promise<void>
updateConfig: <K extends keyof AppConfig>(key: K, value: AppConfig[K]) => void
options: ServiceOptionsResponse
onSave: (event: FormEvent<HTMLFormElement>) => Promise<void>
@@ -3968,11 +4194,15 @@ function AdministrationScreen(props: {
<h1>Settings</h1>
</div>
</header>
{props.notice ? <p className="notice">{props.notice}</p> : null}
{props.error ? <p className="error-banner">{props.error}</p> : null}
<SettingsView
busy={props.busy}
config={props.config}
connections={props.connections}
onCheck={props.onCheck}
onExportBackup={props.onExportBackup}
onImportBackup={props.onImportBackup}
onSave={props.onSave}
options={props.options}
formId="administration-settings"
@@ -3989,10 +4219,13 @@ function SettingsView(props: {
options: ServiceOptionsResponse
onSave: (event: FormEvent<HTMLFormElement>) => Promise<void>
onCheck: () => Promise<void>
onExportBackup: () => Promise<void>
onImportBackup: (file: File) => Promise<void>
busy: boolean
formId?: string
}) {
const check = { busy: props.busy, onCheck: props.onCheck }
const importInputRef = useRef<HTMLInputElement | null>(null)
return (
<form className="settings-page" id={props.formId} onSubmit={(event) => void props.onSave(event)}>
@@ -4078,6 +4311,35 @@ function SettingsView(props: {
</label>
</SettingsCard>
<SettingsCard title="Backup">
<p className="settings-backup-copy">
Saves every service connection and each profile's wishlist to a file. It holds your API keys, so keep it somewhere safe.
Restoring replaces the current settings and wishlists; the master password stays as it is.
</p>
<div className="settings-backup-actions">
<button className="secondary-button" disabled={props.busy} onClick={() => void props.onExportBackup()} type="button">
<DownloadGlyph />
<span>Export</span>
</button>
<button className="secondary-button" disabled={props.busy} onClick={() => importInputRef.current?.click()} type="button">
<span>Import</span>
</button>
<input
accept="application/json,.json"
hidden
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file && window.confirm('Restore this backup? The current settings and wishlists will be replaced.')) {
void props.onImportBackup(file)
}
}}
ref={importInputRef}
type="file"
/>
</div>
</SettingsCard>
</form>
)
}
+22 -1
View File
@@ -2,6 +2,7 @@ import type {
AppConfig,
AuthPayload,
AuthResponse,
BackupFile,
CalendarResponse,
ConnectionStatus,
DashboardResponse,
@@ -17,9 +18,12 @@ import type {
EpisodeWatchStateRequest,
EpisodeRequestPayload,
SeasonDeletePayload,
SeriesEpisodeRequestPayload,
SeasonRequestPayload,
TitleDetailsRequest,
TitleDetailsResponse
TitleDetailsResponse,
WishlistResponse,
WishlistTogglePayload
} from '../shared/contracts'
async function request<T>(input: string, init?: RequestInit): Promise<T> {
@@ -92,6 +96,18 @@ export const api = {
method: 'POST',
body: JSON.stringify(payload),
}),
getWishlist: () => request<WishlistResponse>('/api/wishlist'),
setWishlisted: (payload: WishlistTogglePayload) =>
request<WishlistResponse>('/api/wishlist', {
method: 'POST',
body: JSON.stringify(payload),
}),
exportBackup: () => request<BackupFile>('/api/admin/backup'),
importBackup: (backup: unknown) =>
request<{ config: AppConfig; connections: ConnectionStatus[] }>('/api/admin/backup', {
method: 'POST',
body: JSON.stringify(backup),
}),
getDashboard: () => request<DashboardResponse>('/api/dashboard'),
getDiskSpace: () => request<DiskSpaceStatus | null>('/api/storage'),
getCalendar: (start: Date, end: Date) => request<CalendarResponse>(`/api/calendar?start=${start.toISOString()}&end=${end.toISOString()}`),
@@ -142,6 +158,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ ...payload, user }),
}),
requestSeriesEpisode: (payload: SeriesEpisodeRequestPayload, user: string | null) =>
request<{ ok: boolean }>('/api/requests/series-episode', {
method: 'POST',
body: JSON.stringify({ ...payload, user }),
}),
deleteSeason: (payload: SeasonDeletePayload) =>
request<{ ok: boolean }>('/api/seasons/delete', {
method: 'POST',
+123
View File
@@ -1857,6 +1857,55 @@ img {
gap: 0.6rem;
}
/* Movie header: every control is a plain icon (heart, play, delete, request)
like the ones in a series' season bar, centred on one line and split by
dividers instead of sitting in boxes. */
.title-details-summary-header > .title-season-action,
.title-details-summary-header > .title-season-divider,
.title-details-summary-header .title-summary-header-play,
.title-details-summary-header .title-summary-header-delete,
.title-details-summary-header > .secondary-button {
align-self: center;
}
.title-details-summary-header .title-summary-header-play,
.title-details-summary-header .title-summary-header-delete,
.title-details-summary-header > .secondary-button {
min-width: 1.9rem;
width: auto;
min-height: 1.9rem;
height: auto;
margin-top: 0;
padding: 0.18rem 0.4rem;
border: 0;
border-radius: 0;
background: transparent;
color: rgba(237, 242, 255, 0.82);
font-weight: 600;
transition: color 180ms ease, transform 180ms ease, opacity 180ms ease;
}
.title-details-summary-header .title-summary-header-delete {
padding: 0.18rem;
}
.title-details-summary-header .title-summary-header-play:hover,
.title-details-summary-header .title-summary-header-play:focus-visible,
.title-details-summary-header .title-summary-header-delete:hover,
.title-details-summary-header .title-summary-header-delete:focus-visible,
.title-details-summary-header > .secondary-button:hover,
.title-details-summary-header > .secondary-button:focus-visible {
border: 0;
background: transparent;
color: #ffffff;
transform: translateY(-1px);
}
.title-details-summary-header .title-action-icon {
width: 1.2rem;
height: 1.2rem;
}
.title-movie-request-processing,
.title-movie-request-progress {
display: inline-flex;
@@ -5950,3 +5999,77 @@ img {
outline-offset: 4px;
border-radius: 1rem;
}
/* Wishlist */
.media-wishlist-button {
position: absolute;
top: 0.6rem;
right: 0.6rem;
z-index: 3;
display: grid;
place-items: center;
width: 2.2rem;
height: 2.2rem;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: rgba(6, 8, 12, 0.55);
color: #f3f6ff;
backdrop-filter: blur(8px);
opacity: 0;
transition: opacity 180ms ease, transform 180ms ease, color 180ms ease;
}
.media-wishlist-button .title-action-icon {
width: 1.05rem;
height: 1.05rem;
}
.media-tile:hover .media-wishlist-button,
.media-tile:focus-within .media-wishlist-button,
.media-wishlist-button.is-wishlisted {
opacity: 1;
}
.media-wishlist-button:hover,
.media-wishlist-button:focus-visible {
transform: scale(1.08);
}
.media-wishlist-button.is-wishlisted,
.title-wishlist-button.is-wishlisted {
color: #ff5c7a;
}
/* Phones and touch screens have no hover, and a heart on every poster is noise;
the wishlist stays reachable from the title details. */
@media (hover: none), (max-width: 980px) {
.media-wishlist-button {
display: none;
}
}
.settings-backup-copy {
margin: 0;
color: #98a8c4;
font-size: 0.92rem;
line-height: 1.45;
}
.settings-backup-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.settings-backup-actions .secondary-button {
display: inline-flex;
align-items: center;
gap: 0.6rem;
}
.administration-layout > .notice,
.administration-layout > .error-banner {
max-width: 76rem;
margin: 0 auto 1rem;
}