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