style(web): Seiten auf Mosaik-Muster umgestellt

Einheitliche Seitenkoepfe mit Modul-Kachel, Knopf- und Linkklassen
statt gelber Schrift, Karten ohne harte Rahmen, Tabellenkoepfe ohne
Grossbuchstaben, Marktplatz mit Fluent-Reitern, Filterpillen und
Kachel-Karten, Einstellungs-/Verwaltungsnavigation mit Auswahlpille,
leere Zustaende mit Symbol im Kreis, Proxmox ohne leuchtende Schatten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 14:49:01 +02:00
parent 219038b3ee
commit 30e4bc3a4f
75 changed files with 571 additions and 521 deletions
@@ -83,7 +83,7 @@ export default function ResetPasswordTokenPage() {
{/* Heading */}
<div className="text-center">
<TesseraLogo variant="horizontal" size={32} />
<h2 className="mt-4 text-2xl font-bold text-foreground">
<h2 className="mt-4 text-2xl font-semibold text-foreground">
{t('resetPassword.newPasswordTitle')}
</h2>
</div>
@@ -99,7 +99,7 @@ export default function ResetPasswordTokenPage() {
</p>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary w-full"
>
{t('resetPassword.backToLogin')}
</Link>
@@ -164,7 +164,7 @@ export default function ResetPasswordTokenPage() {
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary w-full"
>
{isPending ? (
<svg
@@ -54,7 +54,7 @@ export default function ResetPasswordPage() {
{/* Heading */}
<div className="text-center">
<TesseraLogo variant="horizontal" size={32} />
<h2 className="mt-4 text-2xl font-bold text-foreground">
<h2 className="mt-4 text-2xl font-semibold text-foreground">
{t('resetPassword.title')}
</h2>
</div>
@@ -67,7 +67,7 @@ export default function ResetPasswordPage() {
</div>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary w-full"
>
{t('resetPassword.backToLogin')}
</Link>
@@ -104,7 +104,7 @@ export default function ResetPasswordPage() {
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary w-full"
>
{isPending ? (
<svg
@@ -88,7 +88,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-4">{t('deleteConfirm.title')}</h2>
{loadingImpact ? (
@@ -116,7 +116,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
<button
type="button"
onClick={onClose}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
@@ -77,7 +77,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-4">
{group ? t('edit') : t('create')}
</h2>
@@ -101,7 +101,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
{!group && (
<p className="text-sm text-muted-foreground">
{t('createLdapHint')}{' '}
<Link href="/admin/ldap" className="text-primary hover:underline">
<Link href="/admin/ldap" className="link">
{t('goToLdap')}
</Link>
</p>
@@ -139,14 +139,14 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
<button
type="button"
onClick={onClose}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="submit"
disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{saving ? tCommon('loading') : tCommon('save')}
</button>
@@ -158,7 +158,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-4">
{t('members.title')} — {group.name}
</h2>
@@ -252,7 +252,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
<button
type="button"
onClick={onClose}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('close')}
</button>
@@ -260,7 +260,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
type="button"
onClick={handleAdd}
disabled={adding || selectedUserIds.length === 0}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{adding ? tCommon('loading') : `${t('members.addTitle')} (${selectedUserIds.length})`}
</button>
@@ -124,11 +124,11 @@ export default function AdminGroupsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{t('create')}
</button>
@@ -149,7 +149,7 @@ export default function AdminGroupsPage() {
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{t('create')}
</button>
+2 -2
View File
@@ -12,9 +12,9 @@ export default function AdminLayout({
const t = useTranslations('settings');
return (
<div className="flex h-full">
<div className="flex h-full flex-col md:flex-row">
<AdminSidebar />
<div className="flex-1 overflow-y-auto p-6">
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
<Link
href="/"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
+11 -11
View File
@@ -547,7 +547,7 @@ export default function AdminLdapPage() {
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
{/* Section 1: Connection Settings */}
<section className="rounded-lg border border-border p-6">
@@ -646,7 +646,7 @@ export default function AdminLdapPage() {
<button
type="submit"
disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{saving ? tCommon('loading') : t('save')}
</button>
@@ -654,7 +654,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleTestConnection}
disabled={!config && !formData.serverUrl}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{t('testConnection')}
</button>
@@ -816,7 +816,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleDiscoverGroups}
disabled={discovering}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{discovering ? tCommon('loading') : t('groupFilter.discover')}
</button>
@@ -919,7 +919,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleSaveGroupFilter}
disabled={savingFilter}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{savingFilter ? tCommon('loading') : t('groupFilter.save')}
</button>
@@ -941,7 +941,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleDiscoverGroupsToImport}
disabled={discoveringGroupImport}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{discoveringGroupImport
? tCommon('loading')
@@ -1015,7 +1015,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleImportGroups}
disabled={importingGroups || selectedImportDns.length === 0}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{importingGroups
? tCommon('loading')
@@ -1142,7 +1142,7 @@ export default function AdminLdapPage() {
type="button"
onClick={() => handleImportUsers(selectedUserDns)}
disabled={importingUsers || selectedUserDns.length === 0}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{importingUsers
? tCommon('loading')
@@ -1234,7 +1234,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleSaveExcludeList}
disabled={savingExclude}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{savingExclude ? tCommon('loading') : t('userExclude.save')}
</button>
@@ -1297,7 +1297,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{t('save')}
</button>
@@ -1319,7 +1319,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleSync}
disabled={syncing}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{syncing ? t('sync.syncing') : t('sync.trigger')}
</button>
@@ -142,7 +142,7 @@ export function ActivateModuleDialog({
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="mb-2 break-words text-lg font-semibold text-foreground">
{t('title', { module: moduleName })}
</h2>
@@ -157,7 +157,7 @@ export function ActivateModuleDialog({
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{t('cancel')}
</button>
@@ -165,7 +165,7 @@ export function ActivateModuleDialog({
type="button"
onClick={handleConfigureLater}
disabled={isSubmitting}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{t('configureLater')}
</button>
@@ -173,7 +173,7 @@ export function ActivateModuleDialog({
type="button"
onClick={handleGrantNow}
disabled={grantNowDisabled}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
className="btn btn-primary"
>
{t('grantNow')}
</button>
@@ -189,7 +189,7 @@ export default function AdminModuleGrantsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
{error && (
@@ -205,7 +205,7 @@ export default function AdminModuleGrantsPage() {
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link
href="/admin/modules"
className="mt-2 inline-block text-sm font-medium text-primary hover:underline"
className="mt-2 inline-block text-sm font-medium link"
>
{t('emptyModulesLink')}
</Link>
@@ -134,12 +134,12 @@ export default function AdminModulesPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<Link
href="/admin/modules/grants"
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{t('grantsLink')}
</Link>
@@ -146,11 +146,11 @@ export default function AdminTenantsPage() {
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{t('create')}
</button>
@@ -244,7 +244,7 @@ export default function AdminTenantsPage() {
{/* Create/Edit modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4">
{editingTenant ? t('edit') : t('create')}
</h2>
@@ -287,13 +287,13 @@ export default function AdminTenantsPage() {
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{tCommon('save')}
</button>
@@ -306,7 +306,7 @@ export default function AdminTenantsPage() {
{/* Delete confirmation modal */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<p className="text-sm text-foreground mb-4">
{t('deleteConfirm')}
</p>
@@ -314,7 +314,7 @@ export default function AdminTenantsPage() {
<button
type="button"
onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
@@ -128,7 +128,7 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2>
<button
@@ -227,11 +227,11 @@ export default function AdminUsersPage() {
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{t('create')}
</button>
@@ -349,7 +349,7 @@ export default function AdminUsersPage() {
{/* Create/Edit modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4">
{editingUser ? t('edit') : t('create')}
</h2>
@@ -455,13 +455,13 @@ export default function AdminUsersPage() {
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{tCommon('save')}
</button>
@@ -474,7 +474,7 @@ export default function AdminUsersPage() {
{/* Delete confirmation modal */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<p className="text-sm text-foreground mb-4">
{t('deleteConfirm')}
</p>
@@ -490,7 +490,7 @@ export default function AdminUsersPage() {
<button
type="button"
onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
@@ -47,7 +47,7 @@ export default function ChangePasswordPage() {
return (
<div className="mx-auto max-w-md py-8 px-4">
<h1 className="text-2xl font-bold text-foreground mb-6">
<h1 className="text-2xl font-semibold text-foreground mb-6">
{t('changePassword.title')}
</h1>
@@ -129,7 +129,7 @@ export default function ChangePasswordPage() {
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary w-full"
>
{isPending ? (
<svg
+4 -4
View File
@@ -1,4 +1,5 @@
import { getTranslations } from 'next-intl/server';
import { PageHeader } from '@/components/layout/page-header';
import { ChangelogView } from '@/components/changelog/changelog-view';
import { appVersion } from '@/lib/app-version';
import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog';
@@ -22,14 +23,13 @@ export default async function ChangelogPage() {
});
return (
<div className="mx-auto max-w-3xl py-8 px-4">
<h1 className="text-2xl font-bold text-foreground mb-2">{t('title')}</h1>
<p className="text-sm text-muted-foreground mb-6">{t('intro')}</p>
<div className="mx-auto max-w-3xl p-3 sm:p-6">
<PageHeader title={t('title')} description={t('intro')} className="mb-6" />
{hasUnreleased && (
<div
data-testid="changelog-unreleased-hint"
className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 px-4 py-3 text-sm text-yellow-800 dark:text-yellow-200 mb-6"
className="mb-6 rounded-md border-l-4 border-status-warn bg-status-warn/10 px-4 py-3 text-sm text-foreground"
>
{t('unreleasedHint')}
</div>
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useToastStore } from '../components/Toast';
@@ -32,6 +34,7 @@ interface CatalogModule {
export default function ModuleDetailPage() {
const { slug } = useParams<{ slug: string }>();
const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
const addToast = useToastStore((s) => s.addToast);
@@ -148,7 +151,7 @@ export default function ModuleDetailPage() {
const isActive = module.isActiveForTenant;
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
@@ -157,23 +160,11 @@ export default function ModuleDetailPage() {
</Link>
<div className="flex items-start gap-4">
<div className="rounded-lg bg-muted p-4 shrink-0">
{module.icon === 'Globe' ? (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
) : (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
)}
</div>
<ModuleTile slug={module.slug} size={56} />
<div>
<h1 className="text-2xl font-bold text-foreground">{module.name}</h1>
<h1 className="text-2xl font-semibold text-foreground">{module.name}</h1>
<div className="flex items-center gap-2 mt-1">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{module.category}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{categoryLabel(module.category)}</span>
<span className="text-xs text-muted-foreground">{t('detailVersion', { version: module.version })}</span>
</div>
</div>
@@ -190,11 +181,7 @@ export default function ModuleDetailPage() {
type="button"
onClick={handleToggle}
disabled={toggling}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
className={`btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
>
{toggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
@@ -45,7 +45,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true">
<div className="max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-2">{t('deactivateDialogTitle')}</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('deactivateDialogBody', { moduleName })}
@@ -55,7 +55,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
type="button"
ref={cancelRef}
onClick={onCancel}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted"
className="btn btn-secondary"
>
{t('deactivateDialogCancel')}
</button>
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCategoryLabel } from '@/lib/use-category-label';
interface CategoryFilterProps {
categories: string[];
@@ -10,17 +11,23 @@ interface CategoryFilterProps {
export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) {
const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
// Dezente Pillen (Fluent „subtle“): gewaehlt = dunkle Fuellung wie bei
// GitHub-Filtern, nicht die gelbe Akzentflaeche.
const chip = (selected: boolean) =>
`inline-flex h-7 items-center rounded-full border px-3 text-xs font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
selected
? 'border-foreground bg-foreground text-background'
: 'border-border bg-card text-foreground hover:bg-muted'
}`;
return (
<div className="flex gap-2 overflow-x-auto pb-1">
<div className="flex gap-2 overflow-x-auto p-0.5 pb-1">
<button
type="button"
onClick={() => onChange('all')}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
aria-pressed={value === 'all'}
className={chip(value === 'all')}
>
{t('categoryAll')}
</button>
@@ -29,13 +36,10 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
key={cat}
type="button"
onClick={() => onChange(cat)}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === cat
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
aria-pressed={value === cat}
className={chip(value === cat)}
>
{cat}
{categoryLabel(cat)}
</button>
))}
</div>
@@ -1,6 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
/**
* Props for the MarketplaceCard component.
@@ -25,53 +27,6 @@ export interface MarketplaceCardProps {
onLockedClick: () => void;
}
/**
* Default icon mapping for known module icons.
* Renders inline SVG to avoid external icon library dependency.
*/
function ModuleIcon({ icon }: { icon?: string }) {
if (icon === 'Globe') {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
}
// Default puzzle-piece icon for generic modules
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
);
}
/**
* Marketplace card showing a module with activation controls.
*
@@ -84,7 +39,6 @@ export function MarketplaceCard({
name,
slug,
description,
icon,
category,
isActive,
hasAccess,
@@ -95,6 +49,7 @@ export function MarketplaceCard({
onLockedClick,
}: MarketplaceCardProps) {
const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.de || '';
@@ -114,10 +69,9 @@ export function MarketplaceCard({
return (
<div
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
locked ? 'opacity-60' : 'hover:shadow-md hover:border-primary/30'
className={`group relative flex min-h-[176px] flex-col rounded-lg bg-card p-4 shadow-sm transition-shadow dark:border dark:border-border ${
locked ? 'opacity-60' : isActive ? 'hover:shadow-md' : ''
}`}
style={{ minHeight: '180px' }}
>
{/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte
Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */}
@@ -126,54 +80,47 @@ export function MarketplaceCard({
type="button"
onClick={handleCardClick}
aria-label={t('openDetail', { name })}
className={`absolute inset-0 rounded-lg ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
className={`absolute inset-0 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
/>
)}
{/* Header: icon + name + badges */}
<div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0">
<ModuleIcon icon={icon} />
</div>
{/* Kopf: App-Kachel + Name + Kategorie */}
<div className="flex items-start gap-3">
<ModuleTile slug={slug} size={40} />
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-base leading-tight truncate">{name}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{category}
</span>
<span
role="status"
className={`rounded-full px-2 py-0.5 text-xs ${
isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-muted text-muted-foreground'
}`}
>
{isActive ? t('statusActive') : t('statusAvailable')}
</span>
{locked && (
<span
className={[
'rounded-full px-2 py-0.5 text-xs',
'bg-amber-100 text-amber-700',
'dark:bg-amber-900/30 dark:text-amber-400',
].join(' ')}
>
{t('statusNoAccess')}
</span>
)}
</div>
<h3 className="truncate text-base font-semibold leading-snug text-foreground">{name}</h3>
<p className="truncate text-xs text-muted-foreground">{categoryLabel(category)}</p>
</div>
</div>
{/* Description */}
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
{/* Beschreibung */}
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
{localizedDescription}
</p>
{/* Footer: action button — only rendered for users who may activate/deactivate */}
{canManage && (
<div className="relative flex items-center justify-end">
{/* Fuss: Status links, Aktion rechts */}
<div className="mt-auto flex items-center justify-between gap-2 pt-4">
<div className="flex flex-wrap items-center gap-1.5">
<span
role="status"
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
isActive
? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-muted text-muted-foreground'
}`}
>
{isActive && <span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-status-ok" />}
{isActive ? t('statusActive') : t('statusAvailable')}
</span>
{locked && (
<span className="rounded-full bg-status-warn/15 px-2 py-0.5 text-xs font-medium text-status-warn-fg">
{t('statusNoAccess')}
</span>
)}
</div>
{/* Aktion — nur fuer Benutzer, die aktivieren/deaktivieren duerfen */}
{canManage && (
<button
type="button"
onClick={(e) => {
@@ -181,11 +128,7 @@ export function MarketplaceCard({
onToggle(id, isActive);
}}
disabled={isToggling}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
className={`relative btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
>
{isToggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
@@ -195,8 +138,8 @@ export function MarketplaceCard({
t('activate')
)}
</button>
</div>
)}
)}
</div>
</div>
);
}
@@ -23,13 +23,30 @@ export function MarketplaceSearch({ onChange }: MarketplaceSearchProps) {
}, [value, onChange]);
return (
<div className="relative">
<div className="relative max-w-md">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute left-2.5 top-2 text-muted-foreground"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t('searchPlaceholder')}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t('searchPlaceholder')}
className="flex h-8 w-full rounded-md border border-input bg-card pl-8 pr-8 text-sm placeholder:text-muted-foreground"
/>
{value && (
<button
@@ -21,25 +21,37 @@ export function StatusFilter({ value, counts, onChange }: StatusFilterProps) {
return (
<div className="flex gap-1 border-b border-border" role="tablist">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={value === tab.key}
onClick={() => onChange(tab.key)}
className={`px-3 py-2 text-sm font-medium transition-colors ${
value === tab.key
? 'text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(tab.label)}
<span className="ml-1.5 text-xs text-muted-foreground">
{counts[tab.key]}
</span>
</button>
))}
{tabs.map((tab) => {
const selected = value === tab.key;
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onChange(tab.key)}
className={`group relative -mb-px inline-flex h-10 items-center px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
selected ? 'font-semibold text-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{t(tab.label)}
<span
className={`ml-2 rounded-full px-1.5 text-xs font-medium ${
selected ? 'bg-foreground/10 text-foreground' : 'bg-muted text-muted-foreground'
}`}
>
{counts[tab.key]}
</span>
{/* Fluent-Reiter: 2 px Akzentlinie unten, beim Zeigen eine graue */}
<span
aria-hidden="true"
className={`absolute inset-x-3 bottom-0 h-[2px] rounded-full ${
selected ? 'bg-primary-strong' : 'bg-transparent group-hover:bg-border'
}`}
/>
</button>
);
})}
</div>
);
}
@@ -37,8 +37,8 @@ export function TenantContextSelector() {
if (!isSuperAdmin) return null;
return (
<div className="bg-muted/50 rounded-lg p-3 mb-4 border border-border">
<label htmlFor="tenant-context-select" className="block text-xs font-semibold text-muted-foreground mb-1.5">
<div className="flex flex-wrap items-center gap-3 rounded-lg bg-card px-4 py-3 shadow-sm dark:border dark:border-border">
<label htmlFor="tenant-context-select" className="text-sm font-semibold text-foreground">
{t('tenantContextLabel')}
</label>
{loading ? (
@@ -48,7 +48,7 @@ export function TenantContextSelector() {
id="tenant-context-select"
onChange={(e) => setSelectedTenantId(e.target.value || null)}
defaultValue=""
className="bg-card border border-border rounded-md px-3 py-1.5 text-sm min-w-[200px]"
className="h-8 min-w-[200px] rounded-md border border-input bg-card px-2.5 text-sm"
>
<option value="">—</option>
{tenants.map((tenant) => (
@@ -39,7 +39,7 @@ export function ToastContainer() {
{toasts.map((toast) => (
<div
key={toast.id}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]"
className="flex items-center gap-3 rounded-lg bg-card dark:border dark:border-border p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]"
>
{toast.type === 'success' ? (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400 shrink-0">
+18 -13
View File
@@ -10,6 +10,7 @@ import { MarketplaceSearch } from './components/MarketplaceSearch';
import { StatusFilter, type StatusValue } from './components/StatusFilter';
import { CategoryFilter } from './components/CategoryFilter';
import { ToastContainer, useToastStore } from './components/Toast';
import { PageHeader } from '@/components/layout/page-header';
import { TenantContextSelector } from './components/TenantContextSelector';
import { ActivationDialog } from './components/ActivationDialog';
@@ -179,13 +180,10 @@ export default function MarketplacePage() {
};
return (
<div className="space-y-6">
<TenantContextSelector />
<div className="mx-auto max-w-7xl space-y-6 p-3 sm:p-6">
<PageHeader title={t('title')} description={t('subtitle')} />
<div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
</div>
<TenantContextSelector />
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
@@ -199,29 +197,30 @@ export default function MarketplacePage() {
</div>
) : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
<span className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground/40 mb-4"
>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<h2 className="text-lg font-semibold text-foreground">{t('emptyTitle')}</h2>
</span>
<h2 className="text-base font-semibold text-foreground">{t('emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p>
</div>
) : (
<>
<div className="space-y-4">
<div className="space-y-3">
<MarketplaceSearch onChange={setSearchQuery} />
<StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} />
<CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} />
@@ -229,7 +228,13 @@ export default function MarketplacePage() {
{filteredModules.length === 0 && hasFiltersApplied ? (
<div className="flex flex-col items-center justify-center min-h-[30vh] text-center">
<h2 className="text-lg font-semibold text-foreground">{t('filteredEmptyTitle')}</h2>
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</span>
<h2 className="text-base font-semibold text-foreground">{t('filteredEmptyTitle')}</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('filteredEmptyBody')}</p>
</div>
) : (
@@ -38,16 +38,16 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-lg bg-muted p-4 mb-4">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
@@ -57,13 +57,13 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
<path d="m9 9 6 6" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">{t('notFound')}</h2>
<h2 className="mb-1 text-base font-semibold">{t('notFound')}</h2>
<p className="text-sm text-muted-foreground mb-6">
{t('notFoundDescription')}
</p>
<Link
href={`/modules/${category}`}
className="text-sm font-medium text-primary hover:underline"
className="btn btn-secondary"
>
{t('backToCategory')}
</Link>
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import Link from 'next/link';
/**
@@ -14,54 +15,6 @@ export interface ModuleCardProps {
category: string;
}
/**
* Default icon mapping for known module icons.
* Renders inline SVG to avoid external icon library dependency.
*/
function ModuleIcon({ icon }: { icon?: string }) {
// Globe icon for domain-related modules
if (icon === 'Globe') {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
}
// Default puzzle-piece icon for generic modules
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
);
}
/**
* Module card for the category grid view.
*
@@ -69,7 +22,7 @@ function ModuleIcon({ icon }: { icon?: string }) {
* to the expanded module view. Per D-05a: uniform card appearance,
* compact, looks good even with a single module.
*/
export function ModuleCard({ name, slug, description, icon, category }: ModuleCardProps) {
export function ModuleCard({ name, slug, description, category }: ModuleCardProps) {
const t = useTranslations('modules');
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.de || '';
@@ -79,14 +32,12 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
href={`/modules/${category}/${slug}`}
className="group block"
>
<div className="rounded-lg border border-border bg-card p-5 shadow-sm transition-all hover:shadow-md hover:border-primary/30 group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2">
<div className="rounded-lg bg-card p-4 shadow-sm transition-shadow hover:shadow-md dark:border dark:border-border group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2">
{/* Header: icon + name */}
<div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0">
<ModuleIcon icon={icon} />
</div>
<ModuleTile slug={slug} size={40} />
<div className="min-w-0">
<h3 className="font-semibold text-base leading-tight truncate">
<h3 className="truncate pt-2 text-base font-semibold leading-snug">
{name}
</h3>
</div>
@@ -99,7 +50,7 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
{/* Footer: open link */}
<div className="flex justify-end">
<span className="text-sm font-medium text-primary group-hover:underline">
<span className="text-sm font-medium text-link group-hover:underline">
{t('openModule')}
</span>
</div>
@@ -95,7 +95,7 @@ export default function CategoryPage() {
<div className="space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">
<h1 className="text-2xl font-semibold tracking-tight">
{t('categoryTitle', { category: categoryTitle })}
</h1>
</div>
@@ -76,7 +76,7 @@ export function ConvertTab({ file, pemText, password, onTargetFormatChange }: Co
type="button"
onClick={handleConvert}
disabled={loading || !canConvert}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.convert')}
</button>
@@ -46,7 +46,7 @@ export function InspectTab({ file, pemText, password }: InspectTabProps) {
type="button"
onClick={handleInspect}
disabled={loading || !canInspect}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.inspect')}
</button>
@@ -139,7 +139,7 @@ export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
data-testid="merge-action-btn"
onClick={handleMerge}
disabled={loading || !canMerge}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.merge')}
</button>
@@ -82,7 +82,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
type="button"
onClick={handleSplit}
disabled={loading || !file}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.split')}
</button>
@@ -1,5 +1,6 @@
'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { DropZone } from './components/DropZone';
@@ -98,15 +99,11 @@ export default function CertManagerPage() {
};
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
{/* Shared input card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
{/* DropZone */}
<DropZone
onFile={handleFile}
@@ -148,7 +145,7 @@ export default function CertManagerPage() {
onClick={() => handleTabChange(tab)}
className={`pb-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'border-b-2 border-primary text-foreground'
? 'border-b-2 border-primary-strong font-semibold text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
@@ -158,7 +155,7 @@ export default function CertManagerPage() {
</nav>
{/* Tab content card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
{renderActiveTab()}
</div>
</div>
@@ -61,7 +61,7 @@ export function ExportFileList({ historyItems }: ExportFileListProps) {
<a
href={exportFileUrl(filename)}
download
className="text-sm text-primary hover:underline"
className="text-sm link"
>
{filename}
</a>
@@ -89,7 +89,7 @@ export function InvoiceHistoryTable({
const totalPages = Math.ceil(total / 25);
const theadCls =
'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
'px-4 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
return (
<div className="rounded border border-border overflow-hidden">
@@ -170,7 +170,7 @@ export function InvoiceHistoryTable({
<a
href={exportFileUrl(row.exportFilename)}
download
className="inline-flex items-center gap-1 text-primary text-sm hover:underline"
className="inline-flex items-center gap-1 link text-sm"
>
<DownloadIcon />
{row.exportFilename}
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api';
import { InvoiceHistoryTable } from './components/InvoiceHistoryTable';
import { ExportFileList } from './components/ExportFileList';
@@ -75,51 +76,47 @@ export default function DkvFleetPage() {
};
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
{/* Page Header Row */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t('pageTitle')}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{lastChecked
? t('lastChecked', { time: formatLastChecked(lastChecked) })
: t('neverChecked')}
</p>
</div>
{/* Einstellungen-Link */}
<Link
href={`${basePath}/settings`}
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t('settingsTitle')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
{/* Seitenkopf (Design „Mosaik“): App-Kachel, Titel, Stand; Aktionen rechts */}
<PageHeader
moduleSlug="dkv-fleet"
title={t('pageTitle')}
description={
lastChecked
? t('lastChecked', { time: formatLastChecked(lastChecked) })
: t('neverChecked')
}
actions={
<>
<Link
href={`${basePath}/settings`}
className="btn btn-secondary btn-icon"
title={t('settingsTitle')}
aria-label={t('settingsTitle')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</Link>
{/* "Jetzt prüfen" button per 07-UI-SPEC button states */}
<button
type="button"
onClick={handleCheckNow}
disabled={isChecking}
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
isChecking ? ' opacity-80 cursor-not-allowed' : ''
}`}
>
{isChecking ? (
<>
<SpinnerIcon />
{t('checking')}
</>
) : (
t('checkNow')
)}
</button>
</div>
</Link>
<button
type="button"
onClick={handleCheckNow}
disabled={isChecking}
className="btn btn-primary"
>
{isChecking ? (
<>
<SpinnerIcon />
{t('checking')}
</>
) : (
t('checkNow')
)}
</button>
</>
}
/>
{/* Error banner — conditional per UI-SPEC */}
{pollError && (
@@ -103,14 +103,14 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button
type="button"
onClick={handleButtonClick}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
className="btn btn-secondary"
>
{t('importCsv')}
</button>
{/* Import mode inline dialog (below button, per UI-SPEC) */}
{showDialog && selectedFile && (
<div className="absolute right-0 top-10 z-30 w-80 rounded border border-border bg-card p-4 shadow-md">
<div className="absolute right-0 top-10 z-30 w-80 rounded bg-card dark:border dark:border-border p-4 shadow-md">
<div className="mb-3 rounded bg-muted/50 px-3 py-2 text-xs text-muted-foreground space-y-0.5">
<p>{t('csvFormatHelp')}</p>
<p className="font-mono">{t('csvFormatExample')}</p>
@@ -185,7 +185,7 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button
type="button"
onClick={handleCancel}
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
className="btn btn-secondary"
>
{t('form.cancelDialog')}
</button>
@@ -525,7 +525,7 @@ export function InboxConfigForm() {
type="button"
onClick={handleTestConnection}
disabled={isTesting || isSaving}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{isTesting ? t('form.testTesting') : t('form.testConnection')}
</button>
@@ -533,7 +533,7 @@ export function InboxConfigForm() {
type="button"
onClick={handleSave}
disabled={isSaving || isTesting}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{t('form.save')}
</button>
@@ -542,7 +542,7 @@ export function InboxConfigForm() {
type="button"
onClick={handleCheckNow}
disabled={isChecking || isSaving || isTesting}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{isChecking ? 'Prüfe...' : 'Jetzt prüfen'}
</button>
@@ -77,7 +77,7 @@ const vehicleToEdit = (v: DkvVehicle): EditState => ({
// --- Shared classes ---
const theadCls =
'px-3 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
'px-3 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
const iconBtnCls =
'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors';
const editInputCls =
@@ -95,7 +95,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
const t = useTranslations('dkvFleet');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="rounded border border-border bg-card px-6 py-5 shadow-lg max-w-sm w-full">
<div className="rounded bg-card dark:border dark:border-border px-6 py-5 shadow-lg max-w-sm w-full">
<h3 className="text-base font-semibold text-foreground mb-2">
{t('deleteVehicle')}
</h3>
@@ -107,7 +107,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
type="button"
onClick={onCancel}
disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{t('form.cancelDialog')}
</button>
@@ -132,7 +132,7 @@ interface ToastProps {
function Toast({ message }: ToastProps) {
return (
<div className="fixed bottom-4 right-4 z-50 rounded border border-border bg-card shadow-md px-4 py-3 text-sm">
<div className="fixed bottom-4 right-4 z-50 rounded bg-card dark:border dark:border-border shadow-md px-4 py-3 text-sm">
{message}
</div>
);
@@ -317,7 +317,7 @@ export function VehicleTable() {
type="button"
onClick={startAdd}
disabled={isAddingNew}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{t('addVehicle')}
</button>
@@ -408,7 +408,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')}
onClick={() => saveEdit(v.id)}
disabled={isSavingRow}
className={`${iconBtnCls} hover:text-primary`}
className={`${iconBtnCls} hover:text-foreground`}
>
<CheckIcon />
</button>
@@ -515,7 +515,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')}
onClick={saveNew}
disabled={isSavingNew}
className={`${iconBtnCls} hover:text-primary`}
className={`${iconBtnCls} hover:text-foreground`}
>
<CheckIcon />
</button>
@@ -24,7 +24,7 @@ export default function DkvFleetSettingsPage() {
const tabCls = (tab: ActiveTab) =>
`px-4 py-2 text-sm transition-colors ${
activeTab === tab
? 'border-b-2 border-primary text-foreground font-medium'
? 'border-b-2 border-primary-strong text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground'
}`;
@@ -33,7 +33,7 @@ export function DomainInput({ onSubmit, isLoading }: DomainInputProps) {
<button
type="submit"
disabled={isLoading || !domain.trim()}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:pointer-events-none disabled:opacity-50"
className="btn btn-primary disabled:pointer-events-none"
>
{isLoading ? t('checking') : t('checkButton')}
</button>
@@ -33,7 +33,7 @@ export function ResultList({ results }: ResultListProps) {
{suggestions.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
<p className="text-xs font-medium text-muted-foreground">
{t('suggestions')}
</p>
{suggestions.map((result) => (
@@ -1,5 +1,6 @@
'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { type DomainCheckResult, checkDomainAction } from './actions';
@@ -37,17 +38,11 @@ export default function DomaincheckPage() {
};
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('description')}
</p>
</div>
<div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="domaincheck" title={t('title')} description={t('description')} />
{/* Card panel (per D-05a) */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
{/* Domain input */}
<DomainInput onSubmit={handleSubmit} isLoading={isLoading} />
@@ -443,8 +443,8 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
data-testid="server-card"
data-health={health}
aria-label={`${server.name}: ${t(`health.${health}`)}`}
className={`relative flex h-full flex-col overflow-hidden rounded-xl border bg-card ${
isOrphan ? 'border-dashed border-border' : 'border-border'
className={`relative flex h-full flex-col overflow-hidden rounded-lg bg-card ${
isOrphan ? 'border border-dashed border-border' : 'dark:border dark:border-border'
} ${style.shadow}`}
>
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { useAuthStore } from '@/lib/stores/auth-store';
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
import { HealthBar } from '@/components/proxmox/HealthBar';
@@ -36,7 +37,7 @@ function RefreshIcon({ spinning }: { spinning: boolean }) {
function SkeletonCard() {
return (
<div className="rounded-xl border border-border bg-card p-4 pl-5 shadow-[0_1px_2px_oklch(0_0_0/0.06)]">
<div className="rounded-xl bg-card p-4 pl-5 shadow-sm dark:border dark:border-border">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-muted" />
<div className="flex-1 space-y-2">
@@ -106,33 +107,31 @@ export default function ProxmoxPage() {
return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
{isAdmin && (
<div className="flex items-center gap-4">
<Link
href="/modules/proxmox/settings"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{t('card.settingsNav')}
</Link>
{servers !== null && servers.length > 0 && (
<button
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-70"
>
<RefreshIcon spinning={isRefreshing} />
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
</button>
)}
</div>
)}
</div>
<PageHeader
moduleSlug="proxmox"
title={t('title')}
description={t('description')}
actions={
isAdmin ? (
<>
<Link href="/modules/proxmox/settings" className="btn btn-subtle">
{t('card.settingsNav')}
</Link>
{servers !== null && servers.length > 0 && (
<button
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
className="btn btn-primary"
>
<RefreshIcon spinning={isRefreshing} />
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
</button>
)}
</>
) : undefined
}
/>
{error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
@@ -179,7 +179,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
};
return (
<div className="space-y-4 rounded-lg border border-border bg-card p-4 shadow-sm">
<div className="space-y-4 rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm">
<div>
<label htmlFor="proxmox-name" className={labelCls}>
{t('settings.nameLabel')}
@@ -368,7 +368,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button"
onClick={handleSave}
disabled={isSaving || !form.name || !form.baseUrl}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{isSaving ? t('settings.saving') : t('settings.save')}
</button>
@@ -383,7 +383,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button"
onClick={handleTest}
disabled={isTesting || !form.baseUrl}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{isTesting ? t('settings.testTesting') : t('settings.testConnection')}
</button>
@@ -17,7 +17,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
const t = useTranslations('proxmox');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded border border-border bg-card px-6 py-5 shadow-lg">
<div className="w-full max-w-sm rounded bg-card dark:border dark:border-border px-6 py-5 shadow-lg">
<h3 className="mb-2 text-base font-semibold text-foreground">
{t('settings.deleteConfirmTitle')}
</h3>
@@ -29,7 +29,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
type="button"
onClick={onCancel}
disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
className="btn btn-secondary"
>
{t('settings.deleteCancelButton')}
</button>
@@ -119,7 +119,7 @@ export default function ProxmoxSettingsPage() {
<button
type="button"
onClick={() => setEditingId('new')}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="btn btn-primary"
>
{t('settings.addServer')}
</button>
@@ -155,7 +155,7 @@ export default function ProxmoxSettingsPage() {
) : (
<li
key={server.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 shadow-sm"
className="flex items-center justify-between rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm"
>
<div>
<div className="font-medium">{server.name}</div>
@@ -167,7 +167,7 @@ export default function ProxmoxSettingsPage() {
<button
type="button"
onClick={() => setEditingId(server.id)}
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
className="btn btn-secondary"
>
{t('settings.edit')}
</button>
@@ -157,7 +157,7 @@ export function FilterPanel() {
const labelCls = 'mb-1 block text-sm text-foreground';
return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-4">
<div className="rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm space-y-4">
<div className="flex flex-wrap items-end gap-4">
<div className="min-w-[220px] flex-1">
<label htmlFor="tr-filter-q" className={labelCls}>
@@ -297,7 +297,7 @@ export function FilterPanel() {
autoComplete="off"
/>
{cpvMatches.length > 0 && (
<ul className="absolute z-10 mt-1 w-full max-h-56 overflow-auto rounded border border-border bg-card shadow-md">
<ul className="absolute z-10 mt-1 w-full max-h-56 overflow-auto rounded bg-card dark:border dark:border-border shadow-md">
{cpvMatches.map((d) => (
<li key={d.code}>
<button
@@ -65,7 +65,7 @@ export function PollNowButton({ onPolled }: PollNowButtonProps) {
onClick={handlePoll}
disabled={isPolling}
title={t('page.pollNowTitle')}
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
className={`btn btn-primary flex items-center${
isPolling ? ' opacity-80 cursor-not-allowed' : ''
}`}
>
@@ -210,7 +210,7 @@ export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) {
const totalPages = Math.max(1, Math.ceil(total / limit));
const thStaticCls =
'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
'px-4 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
const thSortCls = `${thStaticCls} cursor-pointer select-none hover:text-foreground transition-colors`;
return (
@@ -219,7 +219,7 @@ export function SavedSearchBar() {
};
return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-3">
<div className="rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm space-y-3">
<h2 className="text-sm font-semibold text-foreground">{t('savedSearch.title')}</h2>
<div className="flex flex-wrap items-end gap-2">
@@ -276,7 +276,7 @@ export function SavedSearchBar() {
type="button"
aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })}
onClick={() => confirmRename(profile.id)}
className="text-xs font-medium text-primary hover:underline"
className="text-xs font-medium link"
>
{t('savedSearch.save')}
</button>
@@ -67,7 +67,7 @@ function formatPublished(value: string): string {
}
function fieldLabelCls() {
return 'text-xs font-semibold uppercase tracking-wider text-muted-foreground';
return 'text-xs font-semibold text-muted-foreground';
}
/**
@@ -234,7 +234,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
className="inline-block text-sm font-medium link"
>
{t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })}
</a>
@@ -251,7 +251,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
href={tender.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
className="mt-2 inline-block text-sm font-medium link"
>
{t('detail.sourceLinkFallback')}
</a>
@@ -76,7 +76,7 @@ export default function TenderRadarMySourcesPage() {
<div className="mt-8 border-t border-border pt-6">
<Link
href="/modules/tender-radar/settings"
className="text-sm text-primary hover:underline"
className="text-sm link"
>
{t('page.settingsTitle')} &rarr;
</Link>
@@ -4,6 +4,7 @@ import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { CoverageBanner } from './components/CoverageBanner';
import { FilterPanel } from './components/FilterPanel';
import { PollNowButton } from './components/PollNowButton';
@@ -59,42 +60,41 @@ function TenderRadarContent() {
};
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight">
{t('page.title')}
</h1>
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
<div className="flex items-center gap-2">
{/*
* Zahnrad-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): tender-radar routes only
* exist as literal paths — the dynamic settings route returns
* "Modul nicht gefunden" for this module, so the gear must point
* at the literal path directly.
*
* Phase 17 Plan 03 (D-03): points at "Meine Quellen", not the
* admin-only settings page — that's the page every module user
* (not just administrators) is allowed to configure something
* on; administrators reach the platform-wide settings from there
* with one more click.
*/}
<Link
href="/modules/tender-radar/my-sources"
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t('page.mySourcesTitle')}
aria-label={t('page.mySourcesTitle')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<PageHeader
moduleSlug="tender-radar"
title={t('page.title')}
actions={
<>
{/*
* Zahnrad-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): tender-radar routes only
* exist as literal paths — the dynamic settings route returns
* "Modul nicht gefunden" for this module, so the gear must point
* at the literal path directly.
*
* Phase 17 Plan 03 (D-03): points at "Meine Quellen", not the
* admin-only settings page — that's the page every module user
* (not just administrators) is allowed to configure something
* on; administrators reach the platform-wide settings from there
* with one more click.
*/}
<Link
href="/modules/tender-radar/my-sources"
className="btn btn-secondary btn-icon"
title={t('page.mySourcesTitle')}
aria-label={t('page.mySourcesTitle')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</Link>
</Link>
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
</div>
</div>
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
</>
}
/>
<CoverageBanner />
<SavedSearchBar />
@@ -449,7 +449,7 @@ export function EmailAlertConfigForm() {
type="button"
onClick={handleTestConnection}
disabled={isTesting || isSaving}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{isTesting ? t('emailAlerts.testTesting') : t('emailAlerts.testConnection')}
</button>
@@ -457,7 +457,7 @@ export function EmailAlertConfigForm() {
type="button"
onClick={handleSave}
disabled={isSaving || isTesting}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
</button>
@@ -230,7 +230,7 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
type="button"
onClick={handleAdd}
disabled={isAdding}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{isAdding ? t('rssFeeds.adding') : t('rssFeeds.add')}
</button>
@@ -216,7 +216,7 @@ export function SourceConfigForm() {
type="button"
onClick={handleSave}
disabled={isSaving}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{isSaving ? t('sourceConfig.saving') : t('sourceConfig.save')}
</button>
@@ -71,7 +71,7 @@ export default function TenderRadarSettingsPage() {
</p>
<Link
href="/modules/tender-radar/my-sources"
className="text-sm text-primary hover:underline"
className="text-sm link"
>
{t('mySources.title')} &rarr;
</Link>
@@ -17,9 +17,9 @@ export default function SettingsLayout({
const t = useTranslations('settings');
return (
<div className="flex h-full">
<div className="flex h-full flex-col md:flex-row">
<SettingsSidebar />
<div className="flex-1 overflow-y-auto p-6">
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
{/* Back to Dashboard link */}
<Link
href="/"
+108 -1
View File
@@ -306,7 +306,15 @@ body {
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
/* Fluent: deaktiviert = graue Flaeche, graue Schrift (nicht verblasstes Gelb). */
.btn-primary:disabled {
background-color: var(--muted);
color: var(--muted-foreground);
border-color: var(--border);
}
.btn-secondary:disabled, .btn-subtle:disabled {
color: var(--muted-foreground);
}
.btn-primary {
background-color: var(--primary);
@@ -395,3 +403,102 @@ body {
border-radius: 9999px;
background-color: var(--primary-strong);
}
/* Checkboxen und Optionsfelder in der Link-Farbe statt Browser-Blau bzw.
Gelb (Gelb mit weissem Haken waere kaum lesbar). */
input[type="checkbox"], input[type="radio"] {
accent-color: var(--link);
}
/* ---------------------------------------------------------------------------
Dashboard-Kacheln (Design „Mosaik“)
------------------------------------------------------------------------- */
/* Einheitliche Kachel-Kopfzeile: 40 px, 12 px Innenabstand, 14 px halbfett. */
.widget-head {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
/* Aktionen der Kachel erscheinen erst beim Zeigen oder mit Tastaturfokus —
nur auf Geraeten mit Maus; auf Touch bleiben sie sichtbar. */
@media (hover: hover) {
[data-widget-id]:not(:hover):not(:focus-within) .widget-action:not(.widget-action-pinned) {
opacity: 0;
}
}
.widget-action {
transition: opacity 120ms;
}
/* Notiz: ruhige Schrift, Innenabstand wie die Kopfzeile. */
:root .widget-note-body .wmde-markdown {
font-size: 0.875rem;
line-height: 1.5;
}
:root .widget-note-body .w-md-editor-preview,
:root .widget-note-body .wmde-markdown {
padding: 0 0.75rem 0.75rem;
}
:root .widget-note-body .wmde-markdown h1,
:root .widget-note-body .wmde-markdown h2 {
border-bottom: 0;
margin-top: 0.25rem;
font-size: 1rem;
line-height: 1.375rem;
}
/* Bearbeitungsmodus: gestrichelte Akzentkontur und sichtbarer Groessengriff. */
.react-grid-item .widget-drag-handle {
outline: 2px dashed var(--primary-strong);
outline-offset: -2px;
}
.react-grid-item > .react-resizable-handle {
z-index: 20;
}
.react-grid-item > .react-resizable-handle.react-resizable-handle-se {
width: 18px;
height: 18px;
right: 4px;
bottom: 4px;
background-image: none;
border-right: 2px solid var(--primary-strong);
border-bottom: 2px solid var(--primary-strong);
border-bottom-right-radius: 4px;
}
.react-grid-item > .react-resizable-handle.react-resizable-handle-se::after {
display: none;
}
.react-grid-item.react-grid-placeholder {
background: var(--primary);
opacity: 0.18;
border-radius: 0.5rem;
}
/* Mit Hintergrund: Kacheln als Windows-11-„Mica“ — halbtransparent mit
Unschaerfe. Die einzige Stelle mit Transparenz. Deckkraft so gewaehlt,
dass Text auch vor einem rein schwarzen (hell) bzw. rein weissen, dann auf
55 % abgedunkelten (dunkel) Bild 4,5:1 haelt. Ohne backdrop-filter oder
bei „weniger Transparenz“ bleibt die Kachel deckend. */
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
@media not (prefers-reduced-transparency: reduce) {
.dashboard-has-bg [data-widget-id] {
background-color: oklch(1 0 0 / 0.86);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.2);
}
.dark .dashboard-has-bg [data-widget-id] {
background-color: oklch(0.24 0.01 260 / 0.88);
border-color: oklch(1 0 0 / 0.1);
}
}
}
.dashboard-has-bg [data-widget-id] {
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 2px 6px oklch(0 0 0 / 0.16);
}