mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 14:21:18 +01:00
Base url additions and bug fixes (#519)
- Base URL option in settings for reverse proxy setups - Fix NZB downloads not deleting on completion - Fix handling for audiobook files over 100+ parts - Fix prowlarr search timeout - Fix prowlarr categorisation for expanded searches
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Shelfmark - Book search and download" />
|
||||
<base href="/" data-shelfmark-base />
|
||||
|
||||
<!-- Theme color with media queries for light/dark mode -->
|
||||
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
|
||||
@@ -15,8 +16,8 @@
|
||||
<meta name="apple-mobile-web-app-title" content="Shelfmark" />
|
||||
|
||||
<!-- App Icons -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="logo.png" />
|
||||
<title>Shelfmark</title>
|
||||
<script>
|
||||
// Apply theme immediately before first paint to prevent flash
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ConfigSetupBanner } from './components/ConfigSetupBanner';
|
||||
import { OnboardingModal } from './components/OnboardingModal';
|
||||
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
|
||||
import { buildSearchQuery } from './utils/buildSearchQuery';
|
||||
import { withBasePath } from './utils/basePath';
|
||||
import { SearchModeProvider } from './contexts/SearchModeContext';
|
||||
import './styles.css';
|
||||
|
||||
@@ -128,15 +129,6 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [featureNoticeDismissed, setFeatureNoticeDismissed] = useState(() => {
|
||||
return localStorage.getItem('cwa-bd-prowlarr-irc-notice-dismissed') === 'true';
|
||||
});
|
||||
|
||||
const handleDismissFeatureNotice = useCallback(() => {
|
||||
localStorage.setItem('cwa-bd-prowlarr-irc-notice-dismissed', 'true');
|
||||
setFeatureNoticeDismissed(true);
|
||||
}, []);
|
||||
|
||||
// URL-based search: parse URL params for automatic search on page load
|
||||
const urlSearchEnabled = isAuthenticated && config !== null;
|
||||
const { parsedParams, wasProcessed } = useUrlSearch({ enabled: urlSearchEnabled });
|
||||
@@ -210,7 +202,7 @@ function App() {
|
||||
// Auto-download to browser if enabled
|
||||
if (config?.download_to_browser && book.download_path) {
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/localdownload?id=${encodeURIComponent(bookId)}`;
|
||||
link.href = withBasePath(`/api/localdownload?id=${encodeURIComponent(bookId)}`);
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
@@ -626,18 +618,6 @@ function App() {
|
||||
onContentTypeChange={setContentType}
|
||||
/>
|
||||
|
||||
{isInitialState && !featureNoticeDismissed && (
|
||||
<div className="absolute bottom-4 left-0 right-0 px-4 text-center text-sm opacity-40">
|
||||
<span>We've renamed to Shelfmark. New: Torrent, Usenet, IRC and Audiobook support.</span>
|
||||
<button
|
||||
onClick={handleDismissFeatureNotice}
|
||||
className="ml-2 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResultsSection
|
||||
books={books}
|
||||
visible={hasResults}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { StatusData, Book } from '../types';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
|
||||
interface DownloadsSidebarProps {
|
||||
isOpen: boolean;
|
||||
@@ -211,7 +212,7 @@ export const DownloadsSidebar = ({
|
||||
<h3 className="font-semibold text-sm truncate" title={book.title}>
|
||||
{isCompleted && book.download_path ? (
|
||||
<a
|
||||
href={`/api/localdownload?id=${encodeURIComponent(book.id)}`}
|
||||
href={withBasePath(`/api/localdownload?id=${encodeURIComponent(book.id)}`)}
|
||||
className="text-sky-600 hover:underline"
|
||||
>
|
||||
{book.title || 'Unknown Title'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { SearchBar, SearchBarHandle } from './SearchBar';
|
||||
import { ContentType } from '../types';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
|
||||
export interface HeaderHandle {
|
||||
submitSearch: () => void;
|
||||
@@ -345,7 +346,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
// Show persistent toast while gathering logs
|
||||
const loadingToastId = onShowToast?.('Gathering debug logs... This may take a minute.', 'info', true);
|
||||
try {
|
||||
const response = await fetch('/api/debug', {
|
||||
const response = await fetch(withBasePath('/api/debug'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
@@ -394,7 +395,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
</svg>
|
||||
<span>Debug</span>
|
||||
</button>
|
||||
<form action="/api/restart" method="get" className="w-full">
|
||||
<form action={withBasePath('/api/restart')} method="get" className="w-full">
|
||||
<button
|
||||
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
|
||||
type="submit"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { LoginCredentials } from '../types';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSubmit: (credentials: LoginCredentials) => void;
|
||||
@@ -97,7 +98,7 @@ export const LoginForm = ({
|
||||
)}
|
||||
<form
|
||||
method="post"
|
||||
action="/api/login"
|
||||
action={withBasePath('/api/login')}
|
||||
autoComplete="on"
|
||||
id="login-form"
|
||||
name="login"
|
||||
@@ -227,5 +228,3 @@ export const LoginForm = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -75,13 +75,14 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
}
|
||||
|
||||
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
|
||||
const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_auto]';
|
||||
// Use fixed width for delete button column to ensure header/data alignment.
|
||||
const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_2rem]';
|
||||
|
||||
return (
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className={`hidden sm:grid ${gridTemplate} gap-2 px-1 text-xs font-medium opacity-70`}>
|
||||
<div className={`hidden sm:grid ${gridTemplate} gap-3 items-start min-w-0 text-xs font-medium opacity-70`}>
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="truncate">
|
||||
<div key={col.key} className="min-w-0 truncate">
|
||||
{col.label}
|
||||
</div>
|
||||
))}
|
||||
@@ -165,12 +166,12 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-start pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
disabled={isDisabled}
|
||||
className="p-2 rounded hover:bg-[var(--hover-surface)]
|
||||
className="p-1.5 rounded-full hover:bg-[var(--hover-surface)]
|
||||
disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label="Remove row"
|
||||
>
|
||||
|
||||
@@ -66,8 +66,9 @@ export const FieldWrapper = ({
|
||||
disabledOverride,
|
||||
disabledReasonOverride,
|
||||
}: FieldWrapperProps) => {
|
||||
// Action buttons and headings handle their own layout
|
||||
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
|
||||
// Action buttons, headings, and table fields handle their own layout
|
||||
// Table fields have column headers, so they don't need a separate label
|
||||
if (field.type === 'ActionButton' || field.type === 'HeadingField' || field.type === 'TableField') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, ReactNode } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
|
||||
interface SocketContextValue {
|
||||
socket: Socket | null;
|
||||
@@ -23,11 +24,12 @@ export const SocketProvider = ({ children }: SocketProviderProps) => {
|
||||
const wsUrl = window.location.port === '5173'
|
||||
? 'http://localhost:8084'
|
||||
: window.location.origin;
|
||||
const socketPath = withBasePath('/socket.io');
|
||||
|
||||
console.log('SocketProvider: Connecting to', wsUrl);
|
||||
|
||||
const socket = io(wsUrl, {
|
||||
path: '/socket.io',
|
||||
path: socketPath,
|
||||
transports: ['polling', 'websocket'],
|
||||
withCredentials: false,
|
||||
});
|
||||
|
||||
@@ -3,13 +3,17 @@ import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { SocketProvider } from './contexts/SocketContext';
|
||||
import App from './App';
|
||||
import { getBasePath } from './utils/basePath';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('Root element not found');
|
||||
|
||||
const basePath = getBasePath();
|
||||
const routerBase = basePath === '/' ? undefined : basePath;
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={routerBase}>
|
||||
<SocketProvider>
|
||||
<App />
|
||||
</SocketProvider>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse, ReleaseSource, ReleasesResponse } from '../types';
|
||||
import { SettingsResponse, ActionResult, UpdateResult } from '../types/settings';
|
||||
import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers';
|
||||
import { getApiBase } from '../utils/basePath';
|
||||
|
||||
const API_BASE = '/api';
|
||||
const API_BASE = getApiBase();
|
||||
|
||||
// API endpoints
|
||||
const API = {
|
||||
@@ -39,12 +40,17 @@ export class TimeoutError extends Error {
|
||||
|
||||
// Default request timeout in milliseconds (30 seconds)
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const EXPANDED_RELEASES_TIMEOUT_MS = 60000;
|
||||
|
||||
// Utility function for JSON fetch with credentials and timeout
|
||||
async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<T> {
|
||||
async function fetchJSON<T>(
|
||||
url: string,
|
||||
opts: RequestInit = {},
|
||||
timeoutMs: number | null = DEFAULT_TIMEOUT_MS
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const timeoutId = timeoutMs && timeoutMs > 0
|
||||
? setTimeout(() => controller.abort(), timeoutMs)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -60,13 +66,16 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: numb
|
||||
if (!res.ok) {
|
||||
// Try to parse error message from response body
|
||||
let errorMessage = `${res.status} ${res.statusText}`;
|
||||
let hasServerMessage = false;
|
||||
try {
|
||||
const errorData = await res.json();
|
||||
// Prefer user-friendly 'message' field, fall back to 'error'
|
||||
if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
hasServerMessage = true;
|
||||
} else if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
hasServerMessage = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Log parse failure for debugging - server may have returned non-JSON (e.g., HTML error page)
|
||||
@@ -75,7 +84,9 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: numb
|
||||
|
||||
// Provide helpful message for gateway/proxy errors
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) {
|
||||
errorMessage = `Server unavailable (${res.status}). If using a reverse proxy, check its configuration.`;
|
||||
if (!hasServerMessage) {
|
||||
errorMessage = `Server unavailable (${res.status}). If using a reverse proxy, check its configuration.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Throw appropriate error based on status code
|
||||
@@ -94,7 +105,9 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: numb
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +366,6 @@ export const getReleases = async (
|
||||
if (manualQuery) {
|
||||
params.set('manual_query', manualQuery);
|
||||
}
|
||||
const timeoutMs = expandSearch ? EXPANDED_RELEASES_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
||||
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, timeoutMs);
|
||||
// Let the backend control timeouts for release searches (can be long-running).
|
||||
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const normalizeBasePath = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
let path = trimmed;
|
||||
if (!path.startsWith('/')) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
|
||||
if (path.length > 1 && path.endsWith('/')) {
|
||||
path = path.slice(0, -1);
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const resolveBasePath = (): string => {
|
||||
if (typeof document === 'undefined') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const baseHref = document.querySelector('base')?.getAttribute('href') || '/';
|
||||
|
||||
try {
|
||||
return new URL(baseHref, window.location.origin).pathname;
|
||||
} catch {
|
||||
return baseHref;
|
||||
}
|
||||
};
|
||||
|
||||
const BASE_PATH = normalizeBasePath(resolveBasePath());
|
||||
|
||||
export const getBasePath = (): string => BASE_PATH;
|
||||
|
||||
export const withBasePath = (path: string): string => {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
if (BASE_PATH === '/') {
|
||||
return normalizedPath;
|
||||
}
|
||||
return `${BASE_PATH}${normalizedPath}`;
|
||||
};
|
||||
|
||||
export const getApiBase = (): string => withBasePath('/api');
|
||||
@@ -2,7 +2,8 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ command }) => ({
|
||||
base: command === 'build' ? './' : '/',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -29,4 +30,4 @@ export default defineConfig({
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user