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 */} {/* Heading */}
<div className="text-center"> <div className="text-center">
<TesseraLogo variant="horizontal" size={32} /> <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')} {t('resetPassword.newPasswordTitle')}
</h2> </h2>
</div> </div>
@@ -99,7 +99,7 @@ export default function ResetPasswordTokenPage() {
</p> </p>
<Link <Link
href="/login" 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')} {t('resetPassword.backToLogin')}
</Link> </Link>
@@ -164,7 +164,7 @@ export default function ResetPasswordTokenPage() {
<button <button
type="submit" type="submit"
disabled={isPending} 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 ? ( {isPending ? (
<svg <svg
@@ -54,7 +54,7 @@ export default function ResetPasswordPage() {
{/* Heading */} {/* Heading */}
<div className="text-center"> <div className="text-center">
<TesseraLogo variant="horizontal" size={32} /> <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')} {t('resetPassword.title')}
</h2> </h2>
</div> </div>
@@ -67,7 +67,7 @@ export default function ResetPasswordPage() {
</div> </div>
<Link <Link
href="/login" 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')} {t('resetPassword.backToLogin')}
</Link> </Link>
@@ -104,7 +104,7 @@ export default function ResetPasswordPage() {
<button <button
type="submit" type="submit"
disabled={isPending} 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 ? ( {isPending ? (
<svg <svg
@@ -88,7 +88,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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> <h2 className="text-lg font-semibold text-foreground mb-4">{t('deleteConfirm.title')}</h2>
{loadingImpact ? ( {loadingImpact ? (
@@ -116,7 +116,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
<button <button
type="button" type="button"
onClick={onClose} 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')} {tCommon('cancel')}
</button> </button>
@@ -77,7 +77,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <h2 className="text-lg font-semibold text-foreground mb-4">
{group ? t('edit') : t('create')} {group ? t('edit') : t('create')}
</h2> </h2>
@@ -101,7 +101,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
{!group && ( {!group && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('createLdapHint')}{' '} {t('createLdapHint')}{' '}
<Link href="/admin/ldap" className="text-primary hover:underline"> <Link href="/admin/ldap" className="link">
{t('goToLdap')} {t('goToLdap')}
</Link> </Link>
</p> </p>
@@ -139,14 +139,14 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
<button <button
type="button" type="button"
onClick={onClose} 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')} {tCommon('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
disabled={saving} 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')} {saving ? tCommon('loading') : tCommon('save')}
</button> </button>
@@ -158,7 +158,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <h2 className="text-lg font-semibold text-foreground mb-4">
{t('members.title')} — {group.name} {t('members.title')} — {group.name}
</h2> </h2>
@@ -252,7 +252,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
<button <button
type="button" type="button"
onClick={onClose} 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')} {tCommon('close')}
</button> </button>
@@ -260,7 +260,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
type="button" type="button"
onClick={handleAdd} onClick={handleAdd}
disabled={adding || selectedUserIds.length === 0} 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})`} {adding ? tCommon('loading') : `${t('members.addTitle')} (${selectedUserIds.length})`}
</button> </button>
@@ -124,11 +124,11 @@ export default function AdminGroupsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <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 <button
type="button" type="button"
onClick={openCreate} 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')} {t('create')}
</button> </button>
@@ -149,7 +149,7 @@ export default function AdminGroupsPage() {
<button <button
type="button" type="button"
onClick={openCreate} 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')} {t('create')}
</button> </button>
+2 -2
View File
@@ -12,9 +12,9 @@ export default function AdminLayout({
const t = useTranslations('settings'); const t = useTranslations('settings');
return ( return (
<div className="flex h-full"> <div className="flex h-full flex-col md:flex-row">
<AdminSidebar /> <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 <Link
href="/" href="/"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground" 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 ( return (
<div className="space-y-8"> <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 1: Connection Settings */}
<section className="rounded-lg border border-border p-6"> <section className="rounded-lg border border-border p-6">
@@ -646,7 +646,7 @@ export default function AdminLdapPage() {
<button <button
type="submit" type="submit"
disabled={saving} 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')} {saving ? tCommon('loading') : t('save')}
</button> </button>
@@ -654,7 +654,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={!config && !formData.serverUrl} 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')} {t('testConnection')}
</button> </button>
@@ -816,7 +816,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleDiscoverGroups} onClick={handleDiscoverGroups}
disabled={discovering} 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')} {discovering ? tCommon('loading') : t('groupFilter.discover')}
</button> </button>
@@ -919,7 +919,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSaveGroupFilter} onClick={handleSaveGroupFilter}
disabled={savingFilter} 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')} {savingFilter ? tCommon('loading') : t('groupFilter.save')}
</button> </button>
@@ -941,7 +941,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleDiscoverGroupsToImport} onClick={handleDiscoverGroupsToImport}
disabled={discoveringGroupImport} 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 {discoveringGroupImport
? tCommon('loading') ? tCommon('loading')
@@ -1015,7 +1015,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleImportGroups} onClick={handleImportGroups}
disabled={importingGroups || selectedImportDns.length === 0} 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 {importingGroups
? tCommon('loading') ? tCommon('loading')
@@ -1142,7 +1142,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={() => handleImportUsers(selectedUserDns)} onClick={() => handleImportUsers(selectedUserDns)}
disabled={importingUsers || selectedUserDns.length === 0} 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 {importingUsers
? tCommon('loading') ? tCommon('loading')
@@ -1234,7 +1234,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSaveExcludeList} onClick={handleSaveExcludeList}
disabled={savingExclude} 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')} {savingExclude ? tCommon('loading') : t('userExclude.save')}
</button> </button>
@@ -1297,7 +1297,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={saving} 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')} {t('save')}
</button> </button>
@@ -1319,7 +1319,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSync} onClick={handleSync}
disabled={syncing} 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')} {syncing ? t('sync.syncing') : t('sync.trigger')}
</button> </button>
@@ -142,7 +142,7 @@ export function ActivateModuleDialog({
role="dialog" role="dialog"
aria-modal="true" 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"> <h2 className="mb-2 break-words text-lg font-semibold text-foreground">
{t('title', { module: moduleName })} {t('title', { module: moduleName })}
</h2> </h2>
@@ -157,7 +157,7 @@ export function ActivateModuleDialog({
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} 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')} {t('cancel')}
</button> </button>
@@ -165,7 +165,7 @@ export function ActivateModuleDialog({
type="button" type="button"
onClick={handleConfigureLater} onClick={handleConfigureLater}
disabled={isSubmitting} 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')} {t('configureLater')}
</button> </button>
@@ -173,7 +173,7 @@ export function ActivateModuleDialog({
type="button" type="button"
onClick={handleGrantNow} onClick={handleGrantNow}
disabled={grantNowDisabled} 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')} {t('grantNow')}
</button> </button>
@@ -189,7 +189,7 @@ export default function AdminModuleGrantsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div> </div>
{error && ( {error && (
@@ -205,7 +205,7 @@ export default function AdminModuleGrantsPage() {
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p> <p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link <Link
href="/admin/modules" 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')} {t('emptyModulesLink')}
</Link> </Link>
@@ -134,12 +134,12 @@ export default function AdminModulesPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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> <p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div> </div>
<Link <Link
href="/admin/modules/grants" 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')} {t('grantsLink')}
</Link> </Link>
@@ -146,11 +146,11 @@ export default function AdminTenantsPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between"> <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 <button
type="button" type="button"
onClick={openCreate} 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')} {t('create')}
</button> </button>
@@ -244,7 +244,7 @@ export default function AdminTenantsPage() {
{/* Create/Edit modal */} {/* Create/Edit modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <h2 className="text-lg font-bold text-foreground mb-4">
{editingTenant ? t('edit') : t('create')} {editingTenant ? t('edit') : t('create')}
</h2> </h2>
@@ -287,13 +287,13 @@ export default function AdminTenantsPage() {
<button <button
type="button" type="button"
onClick={() => setShowForm(false)} 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')} {tCommon('cancel')}
</button> </button>
<button <button
type="submit" 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')} {tCommon('save')}
</button> </button>
@@ -306,7 +306,7 @@ export default function AdminTenantsPage() {
{/* Delete confirmation modal */} {/* Delete confirmation modal */}
{deleteConfirm && ( {deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <p className="text-sm text-foreground mb-4">
{t('deleteConfirm')} {t('deleteConfirm')}
</p> </p>
@@ -314,7 +314,7 @@ export default function AdminTenantsPage() {
<button <button
type="button" type="button"
onClick={() => setDeleteConfirm(null)} 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')} {tCommon('cancel')}
</button> </button>
@@ -128,7 +128,7 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
role="dialog" role="dialog"
aria-modal="true" 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"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2> <h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2>
<button <button
@@ -227,11 +227,11 @@ export default function AdminUsersPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between"> <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 <button
type="button" type="button"
onClick={openCreate} 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')} {t('create')}
</button> </button>
@@ -349,7 +349,7 @@ export default function AdminUsersPage() {
{/* Create/Edit modal */} {/* Create/Edit modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <h2 className="text-lg font-bold text-foreground mb-4">
{editingUser ? t('edit') : t('create')} {editingUser ? t('edit') : t('create')}
</h2> </h2>
@@ -455,13 +455,13 @@ export default function AdminUsersPage() {
<button <button
type="button" type="button"
onClick={() => setShowForm(false)} 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')} {tCommon('cancel')}
</button> </button>
<button <button
type="submit" 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')} {tCommon('save')}
</button> </button>
@@ -474,7 +474,7 @@ export default function AdminUsersPage() {
{/* Delete confirmation modal */} {/* Delete confirmation modal */}
{deleteConfirm && ( {deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <p className="text-sm text-foreground mb-4">
{t('deleteConfirm')} {t('deleteConfirm')}
</p> </p>
@@ -490,7 +490,7 @@ export default function AdminUsersPage() {
<button <button
type="button" type="button"
onClick={() => setDeleteConfirm(null)} 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')} {tCommon('cancel')}
</button> </button>
@@ -47,7 +47,7 @@ export default function ChangePasswordPage() {
return ( return (
<div className="mx-auto max-w-md py-8 px-4"> <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')} {t('changePassword.title')}
</h1> </h1>
@@ -129,7 +129,7 @@ export default function ChangePasswordPage() {
<button <button
type="submit" type="submit"
disabled={isPending} 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 ? ( {isPending ? (
<svg <svg
+4 -4
View File
@@ -1,4 +1,5 @@
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PageHeader } from '@/components/layout/page-header';
import { ChangelogView } from '@/components/changelog/changelog-view'; import { ChangelogView } from '@/components/changelog/changelog-view';
import { appVersion } from '@/lib/app-version'; import { appVersion } from '@/lib/app-version';
import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog'; import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog';
@@ -22,14 +23,13 @@ export default async function ChangelogPage() {
}); });
return ( return (
<div className="mx-auto max-w-3xl py-8 px-4"> <div className="mx-auto max-w-3xl p-3 sm:p-6">
<h1 className="text-2xl font-bold text-foreground mb-2">{t('title')}</h1> <PageHeader title={t('title')} description={t('intro')} className="mb-6" />
<p className="text-sm text-muted-foreground mb-6">{t('intro')}</p>
{hasUnreleased && ( {hasUnreleased && (
<div <div
data-testid="changelog-unreleased-hint" 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')} {t('unreleasedHint')}
</div> </div>
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; 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 { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useToastStore } from '../components/Toast'; import { useToastStore } from '../components/Toast';
@@ -32,6 +34,7 @@ interface CatalogModule {
export default function ModuleDetailPage() { export default function ModuleDetailPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const t = useTranslations('marketplace'); const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore(); const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
const addToast = useToastStore((s) => s.addToast); const addToast = useToastStore((s) => s.addToast);
@@ -148,7 +151,7 @@ export default function ModuleDetailPage() {
const isActive = module.isActiveForTenant; const isActive = module.isActiveForTenant;
return ( 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"> <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"> <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" /> <path d="m15 18-6-6 6-6" />
@@ -157,23 +160,11 @@ export default function ModuleDetailPage() {
</Link> </Link>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="rounded-lg bg-muted p-4 shrink-0"> <ModuleTile slug={module.slug} size={56} />
{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>
<div> <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"> <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> <span className="text-xs text-muted-foreground">{t('detailVersion', { version: module.version })}</span>
</div> </div>
</div> </div>
@@ -190,11 +181,7 @@ export default function ModuleDetailPage() {
type="button" type="button"
onClick={handleToggle} onClick={handleToggle}
disabled={toggling} disabled={toggling}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${ className={`btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
> >
{toggling ? ( {toggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true"> <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> <h2 className="text-lg font-semibold text-foreground mb-2">{t('deactivateDialogTitle')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
{t('deactivateDialogBody', { moduleName })} {t('deactivateDialogBody', { moduleName })}
@@ -55,7 +55,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
type="button" type="button"
ref={cancelRef} ref={cancelRef}
onClick={onCancel} 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')} {t('deactivateDialogCancel')}
</button> </button>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCategoryLabel } from '@/lib/use-category-label';
interface CategoryFilterProps { interface CategoryFilterProps {
categories: string[]; categories: string[];
@@ -10,17 +11,23 @@ interface CategoryFilterProps {
export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) { export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) {
const t = useTranslations('marketplace'); 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 ( 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 <button
type="button" type="button"
onClick={() => onChange('all')} onClick={() => onChange('all')}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${ aria-pressed={value === 'all'}
value === 'all' className={chip(value === 'all')}
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
> >
{t('categoryAll')} {t('categoryAll')}
</button> </button>
@@ -29,13 +36,10 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
key={cat} key={cat}
type="button" type="button"
onClick={() => onChange(cat)} onClick={() => onChange(cat)}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${ aria-pressed={value === cat}
value === cat className={chip(value === cat)}
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
> >
{cat} {categoryLabel(cat)}
</button> </button>
))} ))}
</div> </div>
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
/** /**
* Props for the MarketplaceCard component. * Props for the MarketplaceCard component.
@@ -25,53 +27,6 @@ export interface MarketplaceCardProps {
onLockedClick: () => void; 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. * Marketplace card showing a module with activation controls.
* *
@@ -84,7 +39,6 @@ export function MarketplaceCard({
name, name,
slug, slug,
description, description,
icon,
category, category,
isActive, isActive,
hasAccess, hasAccess,
@@ -95,6 +49,7 @@ export function MarketplaceCard({
onLockedClick, onLockedClick,
}: MarketplaceCardProps) { }: MarketplaceCardProps) {
const t = useTranslations('marketplace'); const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'; const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.de || ''; const localizedDescription = description[locale] || description.en || description.de || '';
@@ -114,10 +69,9 @@ export function MarketplaceCard({
return ( return (
<div <div
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${ 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' : 'hover:shadow-md hover:border-primary/30' locked ? 'opacity-60' : isActive ? 'hover:shadow-md' : ''
}`} }`}
style={{ minHeight: '180px' }}
> >
{/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte {/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte
Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */} Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */}
@@ -126,54 +80,47 @@ export function MarketplaceCard({
type="button" type="button"
onClick={handleCardClick} onClick={handleCardClick}
aria-label={t('openDetail', { name })} 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 */} {/* Kopf: App-Kachel + Name + Kategorie */}
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3">
<div className="rounded-md bg-muted p-2.5 shrink-0"> <ModuleTile slug={slug} size={40} />
<ModuleIcon icon={icon} />
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-semibold text-base leading-tight truncate">{name}</h3> <h3 className="truncate text-base font-semibold leading-snug text-foreground">{name}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1"> <p className="truncate text-xs text-muted-foreground">{categoryLabel(category)}</p>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"> </div>
{category} </div>
</span>
{/* Beschreibung */}
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
{localizedDescription}
</p>
{/* 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 <span
role="status" role="status"
className={`rounded-full px-2 py-0.5 text-xs ${ className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
isActive isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' ? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-muted text-muted-foreground' : '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')} {isActive ? t('statusActive') : t('statusAvailable')}
</span> </span>
{locked && ( {locked && (
<span <span className="rounded-full bg-status-warn/15 px-2 py-0.5 text-xs font-medium text-status-warn-fg">
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')} {t('statusNoAccess')}
</span> </span>
)} )}
</div> </div>
</div>
</div>
{/* Description */} {/* Aktion — nur fuer Benutzer, die aktivieren/deaktivieren duerfen */}
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
{localizedDescription}
</p>
{/* Footer: action button — only rendered for users who may activate/deactivate */}
{canManage && ( {canManage && (
<div className="relative flex items-center justify-end">
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@@ -181,11 +128,7 @@ export function MarketplaceCard({
onToggle(id, isActive); onToggle(id, isActive);
}} }}
disabled={isToggling} disabled={isToggling}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${ className={`relative btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
> >
{isToggling ? ( {isToggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <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') t('activate')
)} )}
</button> </button>
</div>
)} )}
</div> </div>
</div>
); );
} }
@@ -23,13 +23,30 @@ export function MarketplaceSearch({ onChange }: MarketplaceSearchProps) {
}, [value, onChange]); }, [value, onChange]);
return ( 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 <input
type="text" type="text"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder={t('searchPlaceholder')} 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 && ( {value && (
<button <button
@@ -21,25 +21,37 @@ export function StatusFilter({ value, counts, onChange }: StatusFilterProps) {
return ( return (
<div className="flex gap-1 border-b border-border" role="tablist"> <div className="flex gap-1 border-b border-border" role="tablist">
{tabs.map((tab) => ( {tabs.map((tab) => {
const selected = value === tab.key;
return (
<button <button
key={tab.key} key={tab.key}
type="button" type="button"
role="tab" role="tab"
aria-selected={value === tab.key} aria-selected={selected}
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
className={`px-3 py-2 text-sm font-medium transition-colors ${ 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 ${
value === tab.key selected ? 'font-semibold text-foreground' : 'text-muted-foreground hover:text-foreground'
? 'text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
{t(tab.label)} {t(tab.label)}
<span className="ml-1.5 text-xs text-muted-foreground"> <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]} {counts[tab.key]}
</span> </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> </button>
))} );
})}
</div> </div>
); );
} }
@@ -37,8 +37,8 @@ export function TenantContextSelector() {
if (!isSuperAdmin) return null; if (!isSuperAdmin) return null;
return ( return (
<div className="bg-muted/50 rounded-lg p-3 mb-4 border border-border"> <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="block text-xs font-semibold text-muted-foreground mb-1.5"> <label htmlFor="tenant-context-select" className="text-sm font-semibold text-foreground">
{t('tenantContextLabel')} {t('tenantContextLabel')}
</label> </label>
{loading ? ( {loading ? (
@@ -48,7 +48,7 @@ export function TenantContextSelector() {
id="tenant-context-select" id="tenant-context-select"
onChange={(e) => setSelectedTenantId(e.target.value || null)} onChange={(e) => setSelectedTenantId(e.target.value || null)}
defaultValue="" 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> <option value="">—</option>
{tenants.map((tenant) => ( {tenants.map((tenant) => (
@@ -39,7 +39,7 @@ export function ToastContainer() {
{toasts.map((toast) => ( {toasts.map((toast) => (
<div <div
key={toast.id} 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' ? ( {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"> <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 { StatusFilter, type StatusValue } from './components/StatusFilter';
import { CategoryFilter } from './components/CategoryFilter'; import { CategoryFilter } from './components/CategoryFilter';
import { ToastContainer, useToastStore } from './components/Toast'; import { ToastContainer, useToastStore } from './components/Toast';
import { PageHeader } from '@/components/layout/page-header';
import { TenantContextSelector } from './components/TenantContextSelector'; import { TenantContextSelector } from './components/TenantContextSelector';
import { ActivationDialog } from './components/ActivationDialog'; import { ActivationDialog } from './components/ActivationDialog';
@@ -179,13 +180,10 @@ export default function MarketplacePage() {
}; };
return ( return (
<div className="space-y-6"> <div className="mx-auto max-w-7xl space-y-6 p-3 sm:p-6">
<TenantContextSelector /> <PageHeader title={t('title')} description={t('subtitle')} />
<div> <TenantContextSelector />
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
</div>
{error && ( {error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <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> </div>
) : modules.length === 0 ? ( ) : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center"> <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 <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="64" width="28"
height="64" height="28"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1" strokeWidth="1.75"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="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="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="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" /> <path d="M12 22V12" />
</svg> </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> <p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p>
</div> </div>
) : ( ) : (
<> <>
<div className="space-y-4"> <div className="space-y-3">
<MarketplaceSearch onChange={setSearchQuery} /> <MarketplaceSearch onChange={setSearchQuery} />
<StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} /> <StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} />
<CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} /> <CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} />
@@ -229,7 +228,13 @@ export default function MarketplacePage() {
{filteredModules.length === 0 && hasFiltersApplied ? ( {filteredModules.length === 0 && hasFiltersApplied ? (
<div className="flex flex-col items-center justify-center min-h-[30vh] text-center"> <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> <p className="mt-1 text-sm text-muted-foreground max-w-md">{t('filteredEmptyBody')}</p>
</div> </div>
) : ( ) : (
@@ -38,16 +38,16 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
return ( return (
<div className="mx-auto max-w-2xl space-y-6 p-6"> <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="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 <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="48" width="28"
height="48" height="28"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.75"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="text-muted-foreground" className="text-muted-foreground"
@@ -57,13 +57,13 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
<path d="m9 9 6 6" /> <path d="m9 9 6 6" />
</svg> </svg>
</div> </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"> <p className="text-sm text-muted-foreground mb-6">
{t('notFoundDescription')} {t('notFoundDescription')}
</p> </p>
<Link <Link
href={`/modules/${category}`} href={`/modules/${category}`}
className="text-sm font-medium text-primary hover:underline" className="btn btn-secondary"
> >
{t('backToCategory')} {t('backToCategory')}
</Link> </Link>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import Link from 'next/link'; import Link from 'next/link';
/** /**
@@ -14,54 +15,6 @@ export interface ModuleCardProps {
category: string; 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. * 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, * to the expanded module view. Per D-05a: uniform card appearance,
* compact, looks good even with a single module. * 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 t = useTranslations('modules');
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'; const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.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}`} href={`/modules/${category}/${slug}`}
className="group block" 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 */} {/* Header: icon + name */}
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0"> <ModuleTile slug={slug} size={40} />
<ModuleIcon icon={icon} />
</div>
<div className="min-w-0"> <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} {name}
</h3> </h3>
</div> </div>
@@ -99,7 +50,7 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
{/* Footer: open link */} {/* Footer: open link */}
<div className="flex justify-end"> <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')} {t('openModule')}
</span> </span>
</div> </div>
@@ -95,7 +95,7 @@ export default function CategoryPage() {
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{t('categoryTitle', { category: categoryTitle })} {t('categoryTitle', { category: categoryTitle })}
</h1> </h1>
</div> </div>
@@ -76,7 +76,7 @@ export function ConvertTab({ file, pemText, password, onTargetFormatChange }: Co
type="button" type="button"
onClick={handleConvert} onClick={handleConvert}
disabled={loading || !canConvert} 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')} {loading ? t('actions.processing') : t('actions.convert')}
</button> </button>
@@ -46,7 +46,7 @@ export function InspectTab({ file, pemText, password }: InspectTabProps) {
type="button" type="button"
onClick={handleInspect} onClick={handleInspect}
disabled={loading || !canInspect} 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')} {loading ? t('actions.processing') : t('actions.inspect')}
</button> </button>
@@ -139,7 +139,7 @@ export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
data-testid="merge-action-btn" data-testid="merge-action-btn"
onClick={handleMerge} onClick={handleMerge}
disabled={loading || !canMerge} 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')} {loading ? t('actions.processing') : t('actions.merge')}
</button> </button>
@@ -82,7 +82,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
type="button" type="button"
onClick={handleSplit} onClick={handleSplit}
disabled={loading || !file} 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')} {loading ? t('actions.processing') : t('actions.split')}
</button> </button>
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { DropZone } from './components/DropZone'; import { DropZone } from './components/DropZone';
@@ -98,15 +99,11 @@ export default function CertManagerPage() {
}; };
return ( return (
<div className="mx-auto max-w-4xl space-y-6 p-6"> <div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
{/* Header */} <PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
<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>
{/* Shared input card */} {/* 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 */}
<DropZone <DropZone
onFile={handleFile} onFile={handleFile}
@@ -148,7 +145,7 @@ export default function CertManagerPage() {
onClick={() => handleTabChange(tab)} onClick={() => handleTabChange(tab)}
className={`pb-2 text-sm font-medium transition-colors ${ className={`pb-2 text-sm font-medium transition-colors ${
activeTab === tab 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' : 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
@@ -158,7 +155,7 @@ export default function CertManagerPage() {
</nav> </nav>
{/* Tab content card */} {/* 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()} {renderActiveTab()}
</div> </div>
</div> </div>
@@ -61,7 +61,7 @@ export function ExportFileList({ historyItems }: ExportFileListProps) {
<a <a
href={exportFileUrl(filename)} href={exportFileUrl(filename)}
download download
className="text-sm text-primary hover:underline" className="text-sm link"
> >
{filename} {filename}
</a> </a>
@@ -89,7 +89,7 @@ export function InvoiceHistoryTable({
const totalPages = Math.ceil(total / 25); const totalPages = Math.ceil(total / 25);
const theadCls = 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 ( return (
<div className="rounded border border-border overflow-hidden"> <div className="rounded border border-border overflow-hidden">
@@ -170,7 +170,7 @@ export function InvoiceHistoryTable({
<a <a
href={exportFileUrl(row.exportFilename)} href={exportFileUrl(row.exportFilename)}
download 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 /> <DownloadIcon />
{row.exportFilename} {row.exportFilename}
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api'; import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api';
import { InvoiceHistoryTable } from './components/InvoiceHistoryTable'; import { InvoiceHistoryTable } from './components/InvoiceHistoryTable';
import { ExportFileList } from './components/ExportFileList'; import { ExportFileList } from './components/ExportFileList';
@@ -75,40 +76,34 @@ export default function DkvFleetPage() {
}; };
return ( return (
<div className="mx-auto max-w-5xl space-y-6 p-6"> <div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
{/* Page Header Row */} {/* Seitenkopf (Design „Mosaik“): App-Kachel, Titel, Stand; Aktionen rechts */}
<div className="flex items-start justify-between gap-4"> <PageHeader
<div> moduleSlug="dkv-fleet"
<h1 className="text-2xl font-semibold tracking-tight"> title={t('pageTitle')}
{t('pageTitle')} description={
</h1> lastChecked
<p className="mt-1 text-sm text-muted-foreground">
{lastChecked
? t('lastChecked', { time: formatLastChecked(lastChecked) }) ? t('lastChecked', { time: formatLastChecked(lastChecked) })
: t('neverChecked')} : t('neverChecked')
</p> }
</div> actions={
<>
{/* Einstellungen-Link */}
<Link <Link
href={`${basePath}/settings`} 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" className="btn btn-secondary btn-icon"
title={t('settingsTitle')} 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"> <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" /> <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" /> <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> </svg>
</Link> </Link>
{/* "Jetzt prüfen" button per 07-UI-SPEC button states */}
<button <button
type="button" type="button"
onClick={handleCheckNow} onClick={handleCheckNow}
disabled={isChecking} 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${ className="btn btn-primary"
isChecking ? ' opacity-80 cursor-not-allowed' : ''
}`}
> >
{isChecking ? ( {isChecking ? (
<> <>
@@ -119,7 +114,9 @@ export default function DkvFleetPage() {
t('checkNow') t('checkNow')
)} )}
</button> </button>
</div> </>
}
/>
{/* Error banner — conditional per UI-SPEC */} {/* Error banner — conditional per UI-SPEC */}
{pollError && ( {pollError && (
@@ -103,14 +103,14 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button <button
type="button" type="button"
onClick={handleButtonClick} 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')} {t('importCsv')}
</button> </button>
{/* Import mode inline dialog (below button, per UI-SPEC) */} {/* Import mode inline dialog (below button, per UI-SPEC) */}
{showDialog && selectedFile && ( {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"> <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>{t('csvFormatHelp')}</p>
<p className="font-mono">{t('csvFormatExample')}</p> <p className="font-mono">{t('csvFormatExample')}</p>
@@ -185,7 +185,7 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button <button
type="button" type="button"
onClick={handleCancel} 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')} {t('form.cancelDialog')}
</button> </button>
@@ -525,7 +525,7 @@ export function InboxConfigForm() {
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={isTesting || isSaving} 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')} {isTesting ? t('form.testTesting') : t('form.testConnection')}
</button> </button>
@@ -533,7 +533,7 @@ export function InboxConfigForm() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || isTesting} 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')} {t('form.save')}
</button> </button>
@@ -542,7 +542,7 @@ export function InboxConfigForm() {
type="button" type="button"
onClick={handleCheckNow} onClick={handleCheckNow}
disabled={isChecking || isSaving || isTesting} 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'} {isChecking ? 'Prüfe...' : 'Jetzt prüfen'}
</button> </button>
@@ -77,7 +77,7 @@ const vehicleToEdit = (v: DkvVehicle): EditState => ({
// --- Shared classes --- // --- Shared classes ---
const theadCls = 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 = const iconBtnCls =
'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors'; 'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors';
const editInputCls = const editInputCls =
@@ -95,7 +95,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
const t = useTranslations('dkvFleet'); const t = useTranslations('dkvFleet');
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <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"> <h3 className="text-base font-semibold text-foreground mb-2">
{t('deleteVehicle')} {t('deleteVehicle')}
</h3> </h3>
@@ -107,7 +107,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isDeleting} 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')} {t('form.cancelDialog')}
</button> </button>
@@ -132,7 +132,7 @@ interface ToastProps {
function Toast({ message }: ToastProps) { function Toast({ message }: ToastProps) {
return ( 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} {message}
</div> </div>
); );
@@ -317,7 +317,7 @@ export function VehicleTable() {
type="button" type="button"
onClick={startAdd} onClick={startAdd}
disabled={isAddingNew} 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')} {t('addVehicle')}
</button> </button>
@@ -408,7 +408,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')} aria-label={t('form.saveRow')}
onClick={() => saveEdit(v.id)} onClick={() => saveEdit(v.id)}
disabled={isSavingRow} disabled={isSavingRow}
className={`${iconBtnCls} hover:text-primary`} className={`${iconBtnCls} hover:text-foreground`}
> >
<CheckIcon /> <CheckIcon />
</button> </button>
@@ -515,7 +515,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')} aria-label={t('form.saveRow')}
onClick={saveNew} onClick={saveNew}
disabled={isSavingNew} disabled={isSavingNew}
className={`${iconBtnCls} hover:text-primary`} className={`${iconBtnCls} hover:text-foreground`}
> >
<CheckIcon /> <CheckIcon />
</button> </button>
@@ -24,7 +24,7 @@ export default function DkvFleetSettingsPage() {
const tabCls = (tab: ActiveTab) => const tabCls = (tab: ActiveTab) =>
`px-4 py-2 text-sm transition-colors ${ `px-4 py-2 text-sm transition-colors ${
activeTab === tab 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' : 'text-muted-foreground hover:text-foreground'
}`; }`;
@@ -33,7 +33,7 @@ export function DomainInput({ onSubmit, isLoading }: DomainInputProps) {
<button <button
type="submit" type="submit"
disabled={isLoading || !domain.trim()} 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')} {isLoading ? t('checking') : t('checkButton')}
</button> </button>
@@ -33,7 +33,7 @@ export function ResultList({ results }: ResultListProps) {
{suggestions.length > 0 && ( {suggestions.length > 0 && (
<div className="space-y-2"> <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')} {t('suggestions')}
</p> </p>
{suggestions.map((result) => ( {suggestions.map((result) => (
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { type DomainCheckResult, checkDomainAction } from './actions'; import { type DomainCheckResult, checkDomainAction } from './actions';
@@ -37,17 +38,11 @@ export default function DomaincheckPage() {
}; };
return ( return (
<div className="mx-auto max-w-2xl space-y-6 p-6"> <div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
{/* Header */} <PageHeader moduleSlug="domaincheck" title={t('title')} description={t('description')} />
<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>
{/* Card panel (per D-05a) */} {/* 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 */} {/* Domain input */}
<DomainInput onSubmit={handleSubmit} isLoading={isLoading} /> <DomainInput onSubmit={handleSubmit} isLoading={isLoading} />
@@ -443,8 +443,8 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
data-testid="server-card" data-testid="server-card"
data-health={health} data-health={health}
aria-label={`${server.name}: ${t(`health.${health}`)}`} aria-label={`${server.name}: ${t(`health.${health}`)}`}
className={`relative flex h-full flex-col overflow-hidden rounded-xl border bg-card ${ className={`relative flex h-full flex-col overflow-hidden rounded-lg bg-card ${
isOrphan ? 'border-dashed border-border' : 'border-border' isOrphan ? 'border border-dashed border-border' : 'dark:border dark:border-border'
} ${style.shadow}`} } ${style.shadow}`}
> >
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} /> <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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api'; import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
import { HealthBar } from '@/components/proxmox/HealthBar'; import { HealthBar } from '@/components/proxmox/HealthBar';
@@ -36,7 +37,7 @@ function RefreshIcon({ spinning }: { spinning: boolean }) {
function SkeletonCard() { function SkeletonCard() {
return ( 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="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-muted" /> <div className="h-8 w-8 rounded-lg bg-muted" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
@@ -106,17 +107,14 @@ export default function ProxmoxPage() {
return ( return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6"> <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"> <PageHeader
<div className="min-w-0"> moduleSlug="proxmox"
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1> title={t('title')}
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p> description={t('description')}
</div> actions={
{isAdmin && ( isAdmin ? (
<div className="flex items-center gap-4"> <>
<Link <Link href="/modules/proxmox/settings" className="btn btn-subtle">
href="/modules/proxmox/settings"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{t('card.settingsNav')} {t('card.settingsNav')}
</Link> </Link>
{servers !== null && servers.length > 0 && ( {servers !== null && servers.length > 0 && (
@@ -124,15 +122,16 @@ export default function ProxmoxPage() {
type="button" type="button"
onClick={handleRefresh} onClick={handleRefresh}
disabled={isRefreshing} 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" className="btn btn-primary"
> >
<RefreshIcon spinning={isRefreshing} /> <RefreshIcon spinning={isRefreshing} />
{isRefreshing ? t('card.refreshing') : t('card.refresh')} {isRefreshing ? t('card.refreshing') : t('card.refresh')}
</button> </button>
)} )}
</div> </>
)} ) : undefined
</div> }
/>
{error && ( {error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground"> <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 ( 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> <div>
<label htmlFor="proxmox-name" className={labelCls}> <label htmlFor="proxmox-name" className={labelCls}>
{t('settings.nameLabel')} {t('settings.nameLabel')}
@@ -368,7 +368,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || !form.name || !form.baseUrl} 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')} {isSaving ? t('settings.saving') : t('settings.save')}
</button> </button>
@@ -383,7 +383,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button" type="button"
onClick={handleTest} onClick={handleTest}
disabled={isTesting || !form.baseUrl} 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')} {isTesting ? t('settings.testTesting') : t('settings.testConnection')}
</button> </button>
@@ -17,7 +17,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
const t = useTranslations('proxmox'); const t = useTranslations('proxmox');
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <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"> <h3 className="mb-2 text-base font-semibold text-foreground">
{t('settings.deleteConfirmTitle')} {t('settings.deleteConfirmTitle')}
</h3> </h3>
@@ -29,7 +29,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isDeleting} 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')} {t('settings.deleteCancelButton')}
</button> </button>
@@ -119,7 +119,7 @@ export default function ProxmoxSettingsPage() {
<button <button
type="button" type="button"
onClick={() => setEditingId('new')} 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')} {t('settings.addServer')}
</button> </button>
@@ -155,7 +155,7 @@ export default function ProxmoxSettingsPage() {
) : ( ) : (
<li <li
key={server.id} 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>
<div className="font-medium">{server.name}</div> <div className="font-medium">{server.name}</div>
@@ -167,7 +167,7 @@ export default function ProxmoxSettingsPage() {
<button <button
type="button" type="button"
onClick={() => setEditingId(server.id)} 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')} {t('settings.edit')}
</button> </button>
@@ -157,7 +157,7 @@ export function FilterPanel() {
const labelCls = 'mb-1 block text-sm text-foreground'; const labelCls = 'mb-1 block text-sm text-foreground';
return ( 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="flex flex-wrap items-end gap-4">
<div className="min-w-[220px] flex-1"> <div className="min-w-[220px] flex-1">
<label htmlFor="tr-filter-q" className={labelCls}> <label htmlFor="tr-filter-q" className={labelCls}>
@@ -297,7 +297,7 @@ export function FilterPanel() {
autoComplete="off" autoComplete="off"
/> />
{cpvMatches.length > 0 && ( {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) => ( {cpvMatches.map((d) => (
<li key={d.code}> <li key={d.code}>
<button <button
@@ -65,7 +65,7 @@ export function PollNowButton({ onPolled }: PollNowButtonProps) {
onClick={handlePoll} onClick={handlePoll}
disabled={isPolling} disabled={isPolling}
title={t('page.pollNowTitle')} 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' : '' 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 totalPages = Math.max(1, Math.ceil(total / limit));
const thStaticCls = 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`; const thSortCls = `${thStaticCls} cursor-pointer select-none hover:text-foreground transition-colors`;
return ( return (
@@ -219,7 +219,7 @@ export function SavedSearchBar() {
}; };
return ( 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> <h2 className="text-sm font-semibold text-foreground">{t('savedSearch.title')}</h2>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
@@ -276,7 +276,7 @@ export function SavedSearchBar() {
type="button" type="button"
aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })} aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })}
onClick={() => confirmRename(profile.id)} onClick={() => confirmRename(profile.id)}
className="text-xs font-medium text-primary hover:underline" className="text-xs font-medium link"
> >
{t('savedSearch.save')} {t('savedSearch.save')}
</button> </button>
@@ -67,7 +67,7 @@ function formatPublished(value: string): string {
} }
function fieldLabelCls() { 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} href={source.sourceUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" 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) })} {t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })}
</a> </a>
@@ -251,7 +251,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
href={tender.sourceUrl} href={tender.sourceUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" 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')} {t('detail.sourceLinkFallback')}
</a> </a>
@@ -76,7 +76,7 @@ export default function TenderRadarMySourcesPage() {
<div className="mt-8 border-t border-border pt-6"> <div className="mt-8 border-t border-border pt-6">
<Link <Link
href="/modules/tender-radar/settings" href="/modules/tender-radar/settings"
className="text-sm text-primary hover:underline" className="text-sm link"
> >
{t('page.settingsTitle')} &rarr; {t('page.settingsTitle')} &rarr;
</Link> </Link>
@@ -4,6 +4,7 @@ import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { CoverageBanner } from './components/CoverageBanner'; import { CoverageBanner } from './components/CoverageBanner';
import { FilterPanel } from './components/FilterPanel'; import { FilterPanel } from './components/FilterPanel';
import { PollNowButton } from './components/PollNowButton'; import { PollNowButton } from './components/PollNowButton';
@@ -59,14 +60,12 @@ function TenderRadarContent() {
}; };
return ( return (
<div className="mx-auto max-w-5xl space-y-6 p-6"> <div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<div className="flex items-start justify-between gap-4"> <PageHeader
<h1 className="text-2xl font-bold tracking-tight"> moduleSlug="tender-radar"
{t('page.title')} title={t('page.title')}
</h1> actions={
<>
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
<div className="flex items-center gap-2">
{/* {/*
* Zahnrad-Link. Absolute literal href (NOT the dynamic * Zahnrad-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): tender-radar routes only * [category]/[moduleSlug] basePath): tender-radar routes only
@@ -82,7 +81,7 @@ function TenderRadarContent() {
*/} */}
<Link <Link
href="/modules/tender-radar/my-sources" 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" className="btn btn-secondary btn-icon"
title={t('page.mySourcesTitle')} title={t('page.mySourcesTitle')}
aria-label={t('page.mySourcesTitle')} aria-label={t('page.mySourcesTitle')}
> >
@@ -93,8 +92,9 @@ function TenderRadarContent() {
</Link> </Link>
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} /> <PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
</div> </>
</div> }
/>
<CoverageBanner /> <CoverageBanner />
<SavedSearchBar /> <SavedSearchBar />
@@ -449,7 +449,7 @@ export function EmailAlertConfigForm() {
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={isTesting || isSaving} 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')} {isTesting ? t('emailAlerts.testTesting') : t('emailAlerts.testConnection')}
</button> </button>
@@ -457,7 +457,7 @@ export function EmailAlertConfigForm() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || isTesting} 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')} {isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
</button> </button>
@@ -230,7 +230,7 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
type="button" type="button"
onClick={handleAdd} onClick={handleAdd}
disabled={isAdding} 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')} {isAdding ? t('rssFeeds.adding') : t('rssFeeds.add')}
</button> </button>
@@ -216,7 +216,7 @@ export function SourceConfigForm() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving} 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')} {isSaving ? t('sourceConfig.saving') : t('sourceConfig.save')}
</button> </button>
@@ -71,7 +71,7 @@ export default function TenderRadarSettingsPage() {
</p> </p>
<Link <Link
href="/modules/tender-radar/my-sources" href="/modules/tender-radar/my-sources"
className="text-sm text-primary hover:underline" className="text-sm link"
> >
{t('mySources.title')} &rarr; {t('mySources.title')} &rarr;
</Link> </Link>
@@ -17,9 +17,9 @@ export default function SettingsLayout({
const t = useTranslations('settings'); const t = useTranslations('settings');
return ( return (
<div className="flex h-full"> <div className="flex h-full flex-col md:flex-row">
<SettingsSidebar /> <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 */} {/* Back to Dashboard link */}
<Link <Link
href="/" href="/"
+108 -1
View File
@@ -306,7 +306,15 @@ body {
} }
.btn:disabled { .btn:disabled {
cursor: not-allowed; 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 { .btn-primary {
background-color: var(--primary); background-color: var(--primary);
@@ -395,3 +403,102 @@ body {
border-radius: 9999px; border-radius: 9999px;
background-color: var(--primary-strong); 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);
}
@@ -93,23 +93,23 @@ export function AdminSidebar() {
// das <nav> darunter trAegt die Navigationssemantik schon; ein zweites // das <nav> darunter trAegt die Navigationssemantik schon; ein zweites
// Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer // Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer
// dieselbe Sache. Der aria-label sitzt darum am <nav>, nicht am <aside>. // dieselbe Sache. Der aria-label sitzt darum am <nav>, nicht am <aside>.
<aside className="flex w-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar"> <aside className="flex w-full shrink-0 flex-col border-b border-border py-2 md:w-[220px] md:border-b-0">
<div className="p-4"> <div className="px-5 pb-1 pt-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-xs font-semibold text-muted-foreground">
{t('admin.label')} {t('admin.label')}
</h2> </h2>
</div> </div>
<nav className="flex flex-col gap-1 px-3" aria-label={t('admin.label')}> <nav className="flex flex-col gap-0.5 px-3" aria-label={t('admin.label')}>
{items {items
.filter((item) => item.show) .filter((item) => item.show)
.map((item) => ( .map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${ className={`nav-item flex h-9 items-center gap-2 rounded-lg px-2.5 text-sm transition-colors ${
pathname.startsWith(item.href) pathname.startsWith(item.href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-muted' : 'text-sidebar-foreground hover:bg-sidebar-hover'
}`} }`}
aria-current={pathname.startsWith(item.href) ? 'page' : undefined} aria-current={pathname.startsWith(item.href) ? 'page' : undefined}
> >
@@ -103,7 +103,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
aria-labelledby="bug-report-title" aria-labelledby="bug-report-title"
data-bug-report-ignore="true" data-bug-report-ignore="true"
> >
<div className="w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg"> <div className="w-full max-w-lg rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2"> <h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2">
{t('title')} {t('title')}
</h2> </h2>
@@ -115,7 +115,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90" className="btn btn-primary"
> >
{t('close')} {t('close')}
</button> </button>
@@ -183,7 +183,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={busy} disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50" className="btn btn-secondary"
> >
{t('cancel')} {t('cancel')}
</button> </button>
@@ -191,7 +191,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
type="button" type="button"
onClick={handleSend} onClick={handleSend}
disabled={busy} disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="btn btn-primary"
> >
{busy ? t('sending') : t('send')} {busy ? t('sending') : t('send')}
</button> </button>
+1 -1
View File
@@ -194,7 +194,7 @@ export function Header() {
{/* Dropdown menu */} {/* Dropdown menu */}
{dropdownOpen && ( {dropdownOpen && (
<div className="absolute right-0 top-full z-50 mt-2 w-60 rounded-lg border border-border bg-card py-1.5 text-card-foreground shadow-lg"> <div className="absolute right-0 top-full z-50 mt-2 w-60 rounded-lg bg-card dark:border dark:border-border py-1.5 text-card-foreground shadow-lg">
{/* User info */} {/* User info */}
<div className="px-4 py-2 border-b border-border"> <div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium text-foreground truncate"> <p className="text-sm font-medium text-foreground truncate">
@@ -20,16 +20,16 @@ export function ModuleAccessDenied({ title, body, backToDashboard }: ModuleAcces
return ( return (
<div className="mx-auto max-w-2xl space-y-6 p-6"> <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="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 <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="48" width="28"
height="48" height="28"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.75"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="text-muted-foreground" className="text-muted-foreground"
@@ -38,9 +38,9 @@ export function ModuleAccessDenied({ title, body, backToDashboard }: ModuleAcces
<path d="M7 11V7a5 5 0 0 1 10 0v4" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
</div> </div>
<h2 className="text-xl font-semibold mb-2">{title}</h2> <h2 className="mb-1 text-base font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground mb-6">{body}</p> <p className="text-sm text-muted-foreground mb-6">{body}</p>
<Link href="/" className="text-sm font-medium text-primary hover:underline"> <Link href="/" className="btn btn-primary">
{backToDashboard} {backToDashboard}
</Link> </Link>
</div> </div>
@@ -7,7 +7,8 @@ import type { MeterLevel, ServerHealth } from './proxmox-status';
* *
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 % * `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` = * Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
* zweischichtiger Schatten, der untere im Statuston getoent. * ruhender Fluent-Schatten (Design „Mosaik“: keine im Statuston
* leuchtenden Schatten mehr — der Zustand steht in Leiste und Pille).
* *
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel. * Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel.
*/ */
@@ -19,32 +20,31 @@ export const HEALTH_STYLE: Record<
fill: 'bg-status-ok', fill: 'bg-status-ok',
pill: 'bg-status-ok/12 text-status-ok-fg', pill: 'bg-status-ok/12 text-status-ok-fg',
text: 'text-status-ok-fg', text: 'text-status-ok-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-ok)]', shadow: 'shadow-sm',
}, },
warn: { warn: {
fill: 'bg-status-warn', fill: 'bg-status-warn',
pill: 'bg-status-warn/12 text-status-warn-fg', pill: 'bg-status-warn/12 text-status-warn-fg',
text: 'text-status-warn-fg', text: 'text-status-warn-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-warn)]', shadow: 'shadow-sm',
}, },
down: { down: {
fill: 'bg-status-down', fill: 'bg-status-down',
pill: 'bg-status-down/12 text-status-down-fg', pill: 'bg-status-down/12 text-status-down-fg',
text: 'text-status-down-fg', text: 'text-status-down-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-down)]', shadow: 'shadow-sm',
}, },
idle: { idle: {
fill: 'bg-status-idle', fill: 'bg-status-idle',
pill: 'bg-status-idle/12 text-status-idle-fg', pill: 'bg-status-idle/12 text-status-idle-fg',
text: 'text-status-idle-fg', text: 'text-status-idle-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-idle)]', shadow: 'shadow-sm',
}, },
// Kein farbiger Schatten: ein verwaister Server soll nicht „leuchten“.
orphan: { orphan: {
fill: 'bg-status-orphan', fill: 'bg-status-orphan',
pill: 'bg-status-orphan/12 text-status-orphan-fg', pill: 'bg-status-orphan/12 text-status-orphan-fg',
text: 'text-status-orphan-fg', text: 'text-status-orphan-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.04)]', shadow: 'shadow-sm',
}, },
}; };
@@ -64,4 +64,4 @@ export const METER_TEXT: Record<MeterLevel, string> = {
}; };
/** Eingelassenes Messfeld („Well“). */ /** Eingelassenes Messfeld („Well“). */
export const WELL = 'rounded-lg bg-well shadow-[inset_0_1px_2px_oklch(0_0_0/0.06)]'; export const WELL = 'rounded-lg bg-well';
@@ -127,7 +127,7 @@ export function ReleaseNoticeDialog({ notice, onClose }: ReleaseNoticeDialogProp
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
className="relative z-50 mx-4 flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg border border-border bg-card shadow-xl" className="relative z-50 mx-4 flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg bg-card dark:border dark:border-border shadow-xl"
> >
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4"> <div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
<h2 <h2
@@ -215,7 +215,7 @@ export function ReleaseNoticeDialog({ notice, onClose }: ReleaseNoticeDialogProp
<button <button
type="button" type="button"
onClick={close} onClick={close}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90" className="btn btn-primary"
> >
{t('confirm')} {t('confirm')}
</button> </button>
@@ -197,7 +197,7 @@ export function AccountSettingsForm() {
disabled={isAvatarPending} disabled={isAvatarPending}
onChange={handleAvatarUpload} onChange={handleAvatarUpload}
/> />
<span 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:opacity-50"> <span className="btn btn-primary">
{isAvatarPending ? '...' : t('account.uploadCta')} {isAvatarPending ? '...' : t('account.uploadCta')}
</span> </span>
</label> </label>
@@ -281,7 +281,7 @@ export function AccountSettingsForm() {
type="button" type="button"
onClick={handleSaveAccentColor} onClick={handleSaveAccentColor}
disabled={isAccentPending || !isHexValid} disabled={isAccentPending || !isHexValid}
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:opacity-50" className="btn btn-primary"
> >
{isAccentPending ? '...' : t('account.accentColorSave')} {isAccentPending ? '...' : t('account.accentColorSave')}
</button> </button>
@@ -365,7 +365,7 @@ export function AccountSettingsForm() {
<button <button
type="submit" type="submit"
disabled={isPwPending} disabled={isPwPending}
className="flex 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"
> >
{isPwPending ? ( {isPwPending ? (
<svg aria-hidden="true" className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg aria-hidden="true" className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -358,7 +358,7 @@ export function CalendarSettingsPanel() {
<button <button
type="button" type="button"
onClick={() => setShowAddForm(true)} onClick={() => setShowAddForm(true)}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" className="btn btn-primary"
> >
{t('calendar.addSource')} {t('calendar.addSource')}
</button> </button>
@@ -394,7 +394,7 @@ export function CalendarSettingsPanel() {
/> />
<div <div
className="relative mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl" className="relative mx-4 max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-xl"
role="alertdialog" role="alertdialog"
aria-modal="true" aria-modal="true"
aria-label={t('calendar.deleteDialogLabel')} aria-label={t('calendar.deleteDialogLabel')}
@@ -409,7 +409,7 @@ export function CalendarSettingsPanel() {
<button <button
type="button" type="button"
onClick={() => setDeletingId(null)} onClick={() => setDeletingId(null)}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" className="btn btn-secondary"
> >
{ts('cancel')} {ts('cancel')}
</button> </button>
@@ -355,7 +355,7 @@ export function CalendarSourceForm({
type="submit" type="submit"
data-testid="source-form-submit" data-testid="source-form-submit"
disabled={!isFormValid || isSaving} disabled={!isFormValid || 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('calendar.formSaving') : t('calendar.formSave')} {isSaving ? t('calendar.formSaving') : t('calendar.formSave')}
</button> </button>
@@ -364,14 +364,14 @@ export function CalendarSourceForm({
data-testid="source-form-test" data-testid="source-form-test"
disabled={!isFormValid || testStatus === 'loading'} disabled={!isFormValid || testStatus === 'loading'}
onClick={handleTest} onClick={handleTest}
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"
> >
{testStatus === 'loading' ? t('calendar.formTesting') : t('calendar.formTestConnection')} {testStatus === 'loading' ? t('calendar.formTesting') : t('calendar.formTestConnection')}
</button> </button>
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" className="btn btn-secondary"
> >
{t('calendar.formCancel')} {t('calendar.formCancel')}
</button> </button>
@@ -210,7 +210,7 @@ export function SearchProviderForm() {
type="button" type="button"
onClick={handleAdd} onClick={handleAdd}
disabled={isSubmitting || !newName.trim() || !newUrlTemplate.trim()} disabled={isSubmitting || !newName.trim() || !newUrlTemplate.trim()}
className="h-8 rounded bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50 transition-colors" className="btn btn-primary transition-colors"
> >
{isSubmitting ? 'Adding...' : t('search.addProviderButton')} {isSubmitting ? 'Adding...' : t('search.addProviderButton')}
</button> </button>
@@ -37,20 +37,20 @@ export function SettingsSidebar() {
// darunter stehen zwei <nav>-Bereiche (Allgemein, Dashboard); ein drittes // darunter stehen zwei <nav>-Bereiche (Allgemein, Dashboard); ein drittes
// Navigations-Landmark auf dem Rahmen waere doppelt. Jedes <nav> traegt // Navigations-Landmark auf dem Rahmen waere doppelt. Jedes <nav> traegt
// stattdessen die Beschriftung seiner eigenen Kategorie. // stattdessen die Beschriftung seiner eigenen Kategorie.
<aside className="flex w-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar"> <aside className="flex w-full shrink-0 flex-col border-b border-border py-2 md:w-[220px] md:border-b-0">
{/* Allgemein category — above Dashboard (Surface C, 07-06) */} {/* Allgemein category — above Dashboard (Surface C, 07-06) */}
<div className="p-4 pb-2"> <div className="px-5 pb-1 pt-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-xs font-semibold text-muted-foreground">
{t('categoryGeneral')} {t('categoryGeneral')}
</h2> </h2>
</div> </div>
<nav className="mb-2 flex flex-col gap-1 px-3" aria-label={t('categoryGeneral')}> <nav className="mb-2 flex flex-col gap-0.5 px-3" aria-label={t('categoryGeneral')}>
<Link <Link
href="/settings/general/account" href="/settings/general/account"
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${ className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive('/settings/general/account') isActive('/settings/general/account')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-muted' : 'text-sidebar-foreground hover:bg-sidebar-hover'
}`} }`}
aria-current={ aria-current={
isActive('/settings/general/account') ? 'page' : undefined isActive('/settings/general/account') ? 'page' : undefined
@@ -60,10 +60,10 @@ export function SettingsSidebar() {
</Link> </Link>
<Link <Link
href="/settings/general/desktop" href="/settings/general/desktop"
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${ className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive('/settings/general/desktop') isActive('/settings/general/desktop')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-muted' : 'text-sidebar-foreground hover:bg-sidebar-hover'
}`} }`}
aria-current={ aria-current={
isActive('/settings/general/desktop') ? 'page' : undefined isActive('/settings/general/desktop') ? 'page' : undefined
@@ -74,20 +74,20 @@ export function SettingsSidebar() {
</nav> </nav>
{/* Dashboard category — existing items unchanged */} {/* Dashboard category — existing items unchanged */}
<div className="p-4"> <div className="px-5 pb-1 pt-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-xs font-semibold text-muted-foreground">
{t('categoryDashboard')} {t('categoryDashboard')}
</h2> </h2>
</div> </div>
<nav className="flex flex-col gap-1 px-3" aria-label={t('categoryDashboard')}> <nav className="flex flex-col gap-0.5 px-3" aria-label={t('categoryDashboard')}>
{items.map((item) => ( {items.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${ className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive(item.href) isActive(item.href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-muted' : 'text-sidebar-foreground hover:bg-sidebar-hover'
}`} }`}
aria-current={isActive(item.href) ? 'page' : undefined} aria-current={isActive(item.href) ? 'page' : undefined}
> >
@@ -348,7 +348,7 @@ export function SmtpSettingsForm() {
<button <button
type="submit" type="submit"
disabled={isSaving} disabled={isSaving}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50" className="btn btn-primary"
> >
{t('smtp.save')} {t('smtp.save')}
</button> </button>
@@ -356,7 +356,7 @@ export function SmtpSettingsForm() {
type="button" type="button"
disabled={isTesting} disabled={isTesting}
onClick={handleTest} onClick={handleTest}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" className="btn btn-secondary"
> >
{t('smtp.test')} {t('smtp.test')}
</button> </button>
@@ -451,7 +451,7 @@ function CalendarConfig({
{t('calendar.configSourcesHint')}{' '} {t('calendar.configSourcesHint')}{' '}
<Link <Link
href="/settings/dashboard/calendar" href="/settings/dashboard/calendar"
className="text-primary underline hover:text-primary/90" className="link"
> >
{t('calendar.configSourcesLink')} {t('calendar.configSourcesLink')}
</Link> </Link>
+19 -1
View File
@@ -123,7 +123,25 @@
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"empty": "Keine Widgets aktiv", "empty": "Keine Widgets aktiv",
"addWidget": "Widget hinzufügen" "addWidget": "Widget hinzufügen",
"background": {
"button": "Hintergrund",
"title": "Hintergrund des Dashboards",
"hint": "Gilt nur für Sie und nur in diesem Browser.",
"builtIn": "Eingebaut",
"none": "Keiner",
"ownImages": "Eigene Bilder",
"upload": "Bild hochladen",
"noImages": "Noch keine eigenen Bilder – laden Sie eines hoch oder nutzen Sie Bilder aus dem Bilderrahmen.",
"uploadError": "Das Bild konnte nicht hochgeladen werden.",
"presets": {
"mist": "Nebel",
"sage": "Salbei",
"bloom": "Blüte",
"dunes": "Dünen",
"mosaic": "Mosaik"
}
}
}, },
"settings": { "settings": {
"link": "Einstellungen", "link": "Einstellungen",
+19 -1
View File
@@ -123,7 +123,25 @@
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"empty": "No active widgets", "empty": "No active widgets",
"addWidget": "Add widget" "addWidget": "Add widget",
"background": {
"button": "Background",
"title": "Dashboard background",
"hint": "Applies only to you and only in this browser.",
"builtIn": "Built-in",
"none": "None",
"ownImages": "Your images",
"upload": "Upload image",
"noImages": "No images yet – upload one or use images from the picture frame.",
"uploadError": "The image could not be uploaded.",
"presets": {
"mist": "Mist",
"sage": "Sage",
"bloom": "Bloom",
"dunes": "Dunes",
"mosaic": "Mosaic"
}
}
}, },
"settings": { "settings": {
"link": "Settings", "link": "Settings",