feat: bloc About avec versions dans les settings

- Endpoint GET /version (sans auth) retournant la version API
- Bloc About dans l'onglet General : nom du projet, description,
  versions API et Backoffice, lien GitHub

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 10:41:31 +01:00
parent 18756debfd
commit 98d0f1c9c5
6 changed files with 77 additions and 2 deletions

View File

@@ -7,6 +7,12 @@ pub async fn health() -> &'static str {
"ok" "ok"
} }
pub async fn version() -> Json<serde_json::Value> {
Json(serde_json::json!({
"api": env!("CARGO_PKG_VERSION"),
}))
}
pub async fn docs_redirect() -> impl axum::response::IntoResponse { pub async fn docs_redirect() -> impl axum::response::IntoResponse {
axum::response::Redirect::to("/swagger-ui/") axum::response::Redirect::to("/swagger-ui/")
} }

View File

@@ -199,6 +199,7 @@ async fn main() -> anyhow::Result<()> {
let app = Router::new() let app = Router::new()
.route("/health", get(handlers::health)) .route("/health", get(handlers::health))
.route("/version", get(handlers::version))
.route("/ready", get(handlers::ready)) .route("/ready", get(handlers::ready))
.route("/metrics", get(handlers::metrics)) .route("/metrics", get(handlers::metrics))
.route("/docs", get(handlers::docs_redirect)) .route("/docs", get(handlers::docs_redirect))

View File

@@ -30,9 +30,10 @@ interface SettingsPageProps {
initialStatusMappings: Record<string, unknown>[]; initialStatusMappings: Record<string, unknown>[];
initialSeriesStatuses: string[]; initialSeriesStatuses: string[];
initialProviderStatuses: string[]; initialProviderStatuses: string[];
versions?: { api: string; backoffice: string };
} }
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab, initialProwlarr, initialQbittorrent, initialTorrentImport, initialTelegram, initialAnilist, initialKomga, initialMetadataProviders, initialStatusMappings, initialSeriesStatuses, initialProviderStatuses }: SettingsPageProps) { export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab, initialProwlarr, initialQbittorrent, initialTorrentImport, initialTelegram, initialAnilist, initialKomga, initialMetadataProviders, initialStatusMappings, initialSeriesStatuses, initialProviderStatuses, versions }: SettingsPageProps) {
const { t, locale, setLocale } = useTranslation(); const { t, locale, setLocale } = useTranslation();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -527,6 +528,57 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</CardContent> </CardContent>
</Card> </Card>
{/* About */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="document" size="md" />
{t("settings.about")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-foreground">Stripstream Librarian</h3>
<p className="text-sm text-muted-foreground mt-1">{t("settings.aboutDesc")}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/40">
<div className="w-8 h-8 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Icon name="settings" size="sm" className="text-primary" />
</div>
<div>
<p className="text-xs text-muted-foreground">API</p>
<p className="text-sm font-mono font-medium text-foreground">{versions?.api ?? "?"}</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/40">
<div className="w-8 h-8 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Icon name="books" size="sm" className="text-primary" />
</div>
<div>
<p className="text-xs text-muted-foreground">Backoffice</p>
<p className="text-sm font-mono font-medium text-foreground">{versions?.backoffice ?? "?"}</p>
</div>
</div>
</div>
<div className="flex items-center gap-4 pt-2 text-sm">
<a
href="https://github.com/julienfroidefond/stripstream-librarian"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1.5"
>
<Icon name="externalLink" size="sm" />
GitHub
</a>
</div>
</div>
</CardContent>
</Card>
</>)} </>)}
{activeTab === "metadata" && (<> {activeTab === "metadata" && (<>

View File

@@ -1,11 +1,12 @@
import { getSettings, getCacheStats, getThumbnailStats, fetchUsers, apiFetch } from "@/lib/api"; import { getSettings, getCacheStats, getThumbnailStats, fetchUsers, apiFetch } from "@/lib/api";
import SettingsPage from "./SettingsPage"; import SettingsPage from "./SettingsPage";
import packageJson from "../../../package.json";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function SettingsPageWrapper({ searchParams }: { searchParams: Promise<{ tab?: string }> }) { export default async function SettingsPageWrapper({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
const { tab } = await searchParams; const { tab } = await searchParams;
const [settings, cacheStats, thumbnailStats, users, prowlarr, qbittorrent, torrentImport, telegram, anilist, komga, metadataProviders, statusMappings, seriesStatuses, providerStatuses] = await Promise.all([ const [settings, cacheStats, thumbnailStats, users, prowlarr, qbittorrent, torrentImport, telegram, anilist, komga, metadataProviders, statusMappings, seriesStatuses, providerStatuses, apiVersion] = await Promise.all([
getSettings().catch(() => ({ getSettings().catch(() => ({
image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 }, image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 },
cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 }, cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 },
@@ -25,8 +26,14 @@ export default async function SettingsPageWrapper({ searchParams }: { searchPara
apiFetch<unknown[]>("/settings/status-mappings").catch(() => []), apiFetch<unknown[]>("/settings/status-mappings").catch(() => []),
apiFetch<unknown[]>("/series/statuses").catch(() => []), apiFetch<unknown[]>("/series/statuses").catch(() => []),
apiFetch<unknown[]>("/series/provider-statuses").catch(() => []), apiFetch<unknown[]>("/series/provider-statuses").catch(() => []),
apiFetch<{ api?: string }>("/version").catch(() => ({ api: "?" })),
]); ]);
const versions = {
api: apiVersion?.api ?? "?",
backoffice: packageJson.version,
};
return ( return (
<SettingsPage <SettingsPage
initialSettings={settings} initialSettings={settings}
@@ -44,6 +51,7 @@ export default async function SettingsPageWrapper({ searchParams }: { searchPara
initialStatusMappings={statusMappings as Record<string, unknown>[]} initialStatusMappings={statusMappings as Record<string, unknown>[]}
initialSeriesStatuses={seriesStatuses as string[]} initialSeriesStatuses={seriesStatuses as string[]}
initialProviderStatuses={providerStatuses as string[]} initialProviderStatuses={providerStatuses as string[]}
versions={versions}
/> />
); );
} }

View File

@@ -554,6 +554,10 @@ const en: Record<TranslationKey, string> = {
"settings.totalSize": "Total size", "settings.totalSize": "Total size",
"settings.thumbnailsNote": "Note: Thumbnail settings are used during indexing. Existing thumbnails will not be automatically regenerated. Thumbnail generation concurrency is controlled by the \"Concurrent renders\" setting in Performance limits above.", "settings.thumbnailsNote": "Note: Thumbnail settings are used during indexing. Existing thumbnails will not be automatically regenerated. Thumbnail generation concurrency is controlled by the \"Concurrent renders\" setting in Performance limits above.",
// Settings - About
"settings.about": "About",
"settings.aboutDesc": "Comic book and ebook library manager. Automatic indexing, metadata from multiple providers, integrated search and download, reading tracking.",
// Settings - Komga // Settings - Komga
"settings.komgaSync": "Komga sync", "settings.komgaSync": "Komga sync",
"settings.komgaDesc": "Import reading status from a Komga server. Books are matched by title (case-insensitive). Credentials are not stored.", "settings.komgaDesc": "Import reading status from a Komga server. Books are matched by title (case-insensitive). Credentials are not stored.",

View File

@@ -552,6 +552,10 @@ const fr = {
"settings.totalSize": "Taille totale", "settings.totalSize": "Taille totale",
"settings.thumbnailsNote": "Note : Les paramètres des miniatures sont utilisés pendant l'indexation. Les miniatures existantes ne seront pas regénérées automatiquement. La concurrence de génération des miniatures est contrôlée par le paramètre « Rendus simultanés » dans les Limites de performance ci-dessus.", "settings.thumbnailsNote": "Note : Les paramètres des miniatures sont utilisés pendant l'indexation. Les miniatures existantes ne seront pas regénérées automatiquement. La concurrence de génération des miniatures est contrôlée par le paramètre « Rendus simultanés » dans les Limites de performance ci-dessus.",
// Settings - About
"settings.about": "A propos",
"settings.aboutDesc": "Gestionnaire de bibliothèque de bandes dessinées et ebooks. Indexation automatique, métadonnées depuis plusieurs fournisseurs, recherche et téléchargement intégrés, suivi de lecture.",
// Settings - Komga // Settings - Komga
"settings.komgaSync": "Synchronisation Komga", "settings.komgaSync": "Synchronisation Komga",
"settings.komgaDesc": "Importer le statut de lecture depuis un serveur Komga. Les livres sont associés par titre (insensible à la casse). Les identifiants ne sont pas stockés.", "settings.komgaDesc": "Importer le statut de lecture depuis un serveur Komga. Les livres sont associés par titre (insensible à la casse). Les identifiants ne sont pas stockés.",