refactoring node 24

This commit is contained in:
2025-11-10 10:39:56 +01:00
parent 0515d3d714
commit dff35b8218
10 changed files with 1102 additions and 1019 deletions

View File

@@ -0,0 +1,16 @@
import { Link } from 'react-router-dom';
const AdminAccessMessage = () => (
<div className="p-6 max-w-lg mx-auto bg-white shadow rounded-lg mt-8 text-center">
<h1 className="text-2xl font-semibold text-gray-800 mb-2">Kein Zugriff</h1>
<p className="text-gray-600 mb-4">Dieser Bereich ist nur für Administratoren verfügbar.</p>
<Link
to="/"
className="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Zurück zur Konfiguration
</Link>
</div>
);
export default AdminAccessMessage;

View File

@@ -0,0 +1,253 @@
import { Link } from 'react-router-dom';
const AdminSettingsPanel = ({
adminSettings,
adminSettingsLoading,
status,
error,
onDismissError,
onSettingChange,
onIgnoredSlotChange,
onAddIgnoredSlot,
onRemoveIgnoredSlot,
onNotificationChange,
onSave
}) => {
return (
<div className="p-4 max-w-4xl mx-auto bg-white shadow-lg rounded-lg mt-4">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
<div>
<h1 className="text-2xl font-bold text-purple-900">Admin-Einstellungen</h1>
<p className="text-sm text-gray-600">Globale Abläufe und Verzögerungen für die Abhol-Automation verwalten.</p>
</div>
<Link
to="/"
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border border-purple-200 rounded-md text-purple-700 hover:bg-purple-50 transition-colors"
>
Zur Konfiguration
</Link>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 relative">
<span className="block sm:inline">{error}</span>
<button className="absolute top-0 bottom-0 right-0 px-4 py-3" onClick={onDismissError}>
<span className="text-xl">&times;</span>
</button>
</div>
)}
{status && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4 relative">
<span className="block sm:inline">{status}</span>
</div>
)}
<div className="border border-purple-200 rounded-lg p-4 bg-purple-50">
{adminSettingsLoading && <p className="text-sm text-purple-700">Lade Admin-Einstellungen...</p>}
{!adminSettingsLoading && !adminSettings && <p className="text-sm text-purple-700">Keine Admin-Einstellungen verfügbar.</p>}
{adminSettings && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Cron-Ausdruck</label>
<input
type="text"
value={adminSettings.scheduleCron}
onChange={(event) => onSettingChange('scheduleCron', event.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="z. B. 0 * * * *"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Initiale Verzögerung (Sek.)</label>
<div className="grid grid-cols-2 gap-2">
<input
type="number"
min="0"
value={adminSettings.initialDelayMinSeconds}
onChange={(event) => onSettingChange('initialDelayMinSeconds', event.target.value, true)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Min"
/>
<input
type="number"
min="0"
value={adminSettings.initialDelayMaxSeconds}
onChange={(event) => onSettingChange('initialDelayMaxSeconds', event.target.value, true)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Max"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prüfverzögerung (Sek.)</label>
<div className="grid grid-cols-2 gap-2">
<input
type="number"
min="0"
value={adminSettings.randomDelayMinSeconds}
onChange={(event) => onSettingChange('randomDelayMinSeconds', event.target.value, true)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Min"
/>
<input
type="number"
min="0"
value={adminSettings.randomDelayMaxSeconds}
onChange={(event) => onSettingChange('randomDelayMaxSeconds', event.target.value, true)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Max"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verzögerung Store-Prüfung (ms)</label>
<input
type="number"
min="0"
value={adminSettings.storePickupCheckDelayMs}
onChange={(event) => onSettingChange('storePickupCheckDelayMs', event.target.value, true)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="z. B. 3000"
/>
</div>
</div>
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-purple-900">Ignorierte Slots</h2>
<button
type="button"
onClick={onAddIgnoredSlot}
className="text-sm text-purple-700 hover:text-purple-900 flex items-center gap-1"
>
+ Slot hinzufügen
</button>
</div>
{(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && (
<p className="text-sm text-gray-500">Keine Regeln definiert.</p>
)}
{adminSettings.ignoredSlots?.map((slot, index) => (
<div key={`${index}-${slot.storeId}`} className="grid grid-cols-1 md:grid-cols-5 gap-2 mb-2 items-center">
<input
type="text"
value={slot.storeId}
onChange={(event) => onIgnoredSlotChange(index, 'storeId', event.target.value)}
placeholder="Store-ID"
className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<input
type="text"
value={slot.description}
onChange={(event) => onIgnoredSlotChange(index, 'description', event.target.value)}
placeholder="Beschreibung (optional)"
className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<button
type="button"
onClick={() => onRemoveIgnoredSlot(index)}
className="text-sm text-red-600 hover:text-red-800 focus:outline-none"
>
Entfernen
</button>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-gray-800">ntfy</h3>
<p className="text-xs text-gray-500">Konfiguration für Systembenachrichtigungen via ntfy.</p>
</div>
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
<input
type="checkbox"
checked={adminSettings.notifications?.ntfy?.enabled || false}
onChange={(event) => onNotificationChange('ntfy', 'enabled', event.target.checked)}
className="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
/>
<span>Aktiv</span>
</label>
</div>
<div className="space-y-3">
<input
type="text"
value={adminSettings.notifications?.ntfy?.serverUrl || ''}
onChange={(event) => onNotificationChange('ntfy', 'serverUrl', event.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Server-URL"
/>
<input
type="text"
value={adminSettings.notifications?.ntfy?.topicPrefix || ''}
onChange={(event) => onNotificationChange('ntfy', 'topicPrefix', event.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Themenpräfix"
/>
<input
type="text"
value={adminSettings.notifications?.ntfy?.username || ''}
onChange={(event) => onNotificationChange('ntfy', 'username', event.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Benutzername"
/>
<input
type="password"
value={adminSettings.notifications?.ntfy?.password || ''}
onChange={(event) => onNotificationChange('ntfy', 'password', event.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Passwort"
/>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-gray-800">Telegram</h3>
<p className="text-xs text-gray-500">Bot-Konfiguration für Telegram-Benachrichtigungen.</p>
</div>
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
<input
type="checkbox"
checked={adminSettings.notifications?.telegram?.enabled || false}
onChange={(event) => onNotificationChange('telegram', 'enabled', event.target.checked)}
className="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
/>
<span>Aktiv</span>
</label>
</div>
<input
type="text"
value={adminSettings.notifications?.telegram?.botToken || ''}
onChange={(event) => onNotificationChange('telegram', 'botToken', event.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Bot-Token"
/>
</div>
</div>
<div className="flex justify-end mt-4">
<button
type="button"
onClick={onSave}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-purple-500"
>
Admin-Einstellungen speichern
</button>
</div>
</>
)}
</div>
</div>
);
};
export default AdminSettingsPanel;

View File

@@ -0,0 +1,44 @@
const ConfirmationDialog = ({
open,
title,
message,
confirmLabel,
cancelLabel,
confirmTone = 'primary',
onConfirm,
onCancel
}) => {
if (!open) {
return null;
}
const confirmClasses =
confirmTone === 'danger'
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
<div className="bg-white rounded-lg shadow-2xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-2">{title || 'Bitte bestätigen'}</h3>
<p className="text-sm text-gray-600 mb-4">{message || 'Bist du sicher?'}</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
onClick={onCancel}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-300"
>
{cancelLabel || 'Abbrechen'}
</button>
<button
onClick={onConfirm}
className={`${confirmClasses} text-white px-4 py-2 rounded focus:outline-none focus:ring-2`}
>
{confirmLabel || 'Ja'}
</button>
</div>
</div>
</div>
);
};
export default ConfirmationDialog;

View File

@@ -0,0 +1,299 @@
import NotificationPanel from './NotificationPanel';
const DashboardView = ({
session,
onRefresh,
onLogout,
notificationPanelOpen,
onToggleNotificationPanel,
notificationProps,
stores,
availableCollapsed,
onToggleStores,
onStoreSelect,
configMap,
error,
onDismissError,
status,
visibleConfig,
config,
onToggleActive,
onToggleProfileCheck,
onToggleOnlyNotify,
onWeekdayChange,
weekdays,
onRangePickerRequest,
formatRangeLabel,
onSaveConfig,
onResetConfig
}) => {
const {
error: notificationError,
message: notificationMessage,
settings: notificationSettings,
capabilities: notificationCapabilities,
loading: notificationLoading,
dirty: notificationDirty,
saving: notificationSaving,
onReset: onNotificationReset,
onSave: onNotificationSave,
onFieldChange: onNotificationFieldChange,
onSendTest: onNotificationTest,
onCopyLink: onNotificationCopy,
copyFeedback,
ntfyPreviewUrl
} = notificationProps;
return (
<div className="p-4 max-w-6xl mx-auto bg-white shadow-lg rounded-lg mt-4">
<h1 className="text-2xl font-bold mb-4 text-center text-gray-800">Foodsharing Pickup Manager</h1>
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4 mb-4">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<p className="text-sm uppercase text-blue-600 font-semibold">Angemeldet</p>
<p className="text-lg font-medium text-gray-800">{session.profile.name}</p>
<p className="text-gray-500 text-sm">Profil-ID: {session.profile.id}</p>
</div>
<div className="flex flex-wrap gap-2 items-center">
<button
onClick={() => onRefresh({ block: false })}
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
>
Betriebe aktualisieren
</button>
<button
onClick={onLogout}
className="bg-gray-200 hover:bg-gray-300 text-gray-800 py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-gray-400 transition-colors"
>
Logout
</button>
<button
type="button"
onClick={onToggleNotificationPanel}
className={`flex items-center justify-center w-11 h-11 rounded-full border ${
notificationPanelOpen ? 'border-blue-500 text-blue-600' : 'border-gray-300 text-gray-600'
} hover:text-blue-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 transition`}
title="Benachrichtigungen konfigurieren"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.89 3.31.877 2.42 2.42a1.724 1.724 0 0 0 1.065 2.572c1.757.426 1.757 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.89 1.543-.877 3.31-2.42 2.42a1.724 1.724 0 0 0-2.572 1.065c-.426 1.757-2.924 1.757-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.89-3.31-.877-2.42-2.42a1.724 1.724 0 0 0-1.065-2.572c-1.757-.426-1.757-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.89-1.543.877-3.31 2.42-2.42.996.575 2.273.155 2.573-1.065z"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
</svg>
</button>
{notificationLoading && <span className="text-sm text-gray-500 ml-1">Lade</span>}
</div>
</div>
</div>
{notificationPanelOpen && (
<NotificationPanel
error={notificationError}
message={notificationMessage}
settings={notificationSettings}
capabilities={notificationCapabilities}
loading={notificationLoading}
dirty={notificationDirty}
saving={notificationSaving}
onReset={onNotificationReset}
onSave={onNotificationSave}
onFieldChange={onNotificationFieldChange}
onSendTest={onNotificationTest}
onCopyLink={onNotificationCopy}
copyFeedback={copyFeedback}
ntfyPreviewUrl={ntfyPreviewUrl}
/>
)}
<div className="mb-6 border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={onToggleStores}
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
<span className="font-semibold text-gray-800">Verfügbare Betriebe ({stores.length})</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-5 w-5 text-gray-600 transition-transform ${availableCollapsed ? '' : 'rotate-180'}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{!availableCollapsed && (
<div className="max-h-72 overflow-y-auto divide-y divide-gray-100">
{stores.map((store) => {
const storeId = String(store.id);
const entry = configMap.get(storeId);
const isVisible = entry && !entry.hidden;
const blockedByNoPickups = store.hasPickupSlots === false;
let statusLabel = 'Hinzufügen';
let statusClass = 'text-blue-600';
if (isVisible) {
statusLabel = 'Bereits in Konfiguration';
statusClass = 'text-gray-500';
} else if (entry) {
statusLabel = 'Ausgeblendet erneut hinzufügen';
statusClass = 'text-amber-600';
} else if (blockedByNoPickups) {
statusLabel = 'Keine Pickups automatisch verborgen';
statusClass = 'text-red-600';
}
return (
<div
key={storeId}
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition cursor-default"
>
<div>
<p className="font-semibold text-gray-800">{store.name}</p>
<p className="text-sm text-gray-500">
{store.zip} {store.city}
</p>
<p className={`text-xs mt-2 ${statusClass}`}>{statusLabel}</p>
</div>
<button
onClick={() => onStoreSelect(store)}
disabled={blockedByNoPickups}
className="bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200 px-3 py-1.5 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-300 disabled:opacity-60"
>
{isVisible ? 'Zur Liste springen' : 'Hinzufügen'}
</button>
</div>
);
})}
</div>
)}
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 relative">
<span className="block sm:inline">{error}</span>
<button className="absolute top-0 bottom-0 right-0 px-4 py-3" onClick={onDismissError}>
<span className="text-xl">&times;</span>
</button>
</div>
)}
{status && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4 relative">
<span className="block sm:inline">{status}</span>
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-2">Aktiv</th>
<th className="px-4 py-2">Geschäft</th>
<th className="px-4 py-2">Profil prüfen</th>
<th className="px-4 py-2">Nur benachrichtigen</th>
<th className="px-4 py-2">Wochentag</th>
<th className="px-4 py-2">Datum / Zeitraum</th>
</tr>
</thead>
<tbody>
{visibleConfig.map((item, index) => {
const normalizedRange = item.desiredDateRange
? { ...item.desiredDateRange }
: item.desiredDate
? { start: item.desiredDate, end: item.desiredDate }
: null;
const rangeStart = normalizedRange?.start || '';
const rangeEnd = normalizedRange?.end || '';
const hasDateRange = Boolean(rangeStart || rangeEnd);
return (
<tr key={item.id || index} className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'}>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={item.active}
onChange={() => onToggleActive(item.id)}
className="h-5 w-5"
/>
</td>
<td className="px-4 py-2">
<span className="font-medium">{item.label}</span>
{item.hidden && <span className="ml-2 text-xs text-gray-400">(ausgeblendet)</span>}
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={item.checkProfileId}
onChange={() => onToggleProfileCheck(item.id)}
className="h-5 w-5"
/>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={item.onlyNotify}
onChange={() => onToggleOnlyNotify(item.id)}
className="h-5 w-5"
/>
</td>
<td className="px-4 py-2">
<select
value={item.desiredWeekday || ''}
onChange={(event) => onWeekdayChange(item.id, event.target.value)}
className="border rounded p-1 w-full"
disabled={hasDateRange}
>
<option value="">Kein Wochentag</option>
{weekdays.map((day) => (
<option key={day} value={day}>
{day}
</option>
))}
</select>
</td>
<td className="px-4 py-2">
<button
type="button"
onClick={() => {
if (item.desiredWeekday) {
return;
}
onRangePickerRequest(item.id);
}}
disabled={Boolean(item.desiredWeekday)}
className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
item.desiredWeekday
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:border-blue-400'
}`}
>
<span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span>
<span className="block text-xs text-gray-500">Klicke zum Auswählen</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-6 flex justify-between">
<button onClick={onResetConfig} className="bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded">
Zurücksetzen
</button>
<button onClick={onSaveConfig} className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded">
In ioBroker speichern
</button>
</div>
<div className="mt-8 p-4 border rounded bg-gray-50">
<h2 className="text-lg font-bold mb-2">Aktuelle JSON-Konfiguration:</h2>
<pre className="bg-gray-100 p-4 rounded overflow-x-auto">{JSON.stringify(config, null, 2)}</pre>
</div>
</div>
);
};
export default DashboardView;

View File

@@ -0,0 +1,39 @@
const DirtyNavigationDialog = ({ open, message, onSave, onDiscard, onCancel, saving }) => {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
<div className="bg-white rounded-lg shadow-2xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-2">Änderungen nicht gespeichert</h3>
<p className="text-sm text-gray-600 mb-4">
{message || 'Es gibt ungespeicherte Änderungen. Wie möchtest du fortfahren?'}
</p>
<div className="flex flex-col gap-2">
<button
onClick={onSave}
disabled={saving}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-70"
>
{saving ? 'Speichere...' : 'Speichern & fortfahren'}
</button>
<button
onClick={onDiscard}
className="bg-white border border-gray-300 hover:bg-gray-50 text-gray-800 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-400"
>
Ohne Speichern fortfahren
</button>
<button
onClick={onCancel}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-300"
>
Abbrechen
</button>
</div>
</div>
</div>
);
};
export default DirtyNavigationDialog;

View File

@@ -0,0 +1,73 @@
const LoginView = ({ credentials, onCredentialsChange, error, loading, initializing, onSubmit }) => {
const handleChange = (field) => (event) => {
onCredentialsChange({ ...credentials, [field]: event.target.value });
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<form
className="bg-white shadow-lg rounded-lg p-8 w-full max-w-md space-y-6"
onSubmit={(event) => {
if (initializing) {
event.preventDefault();
return;
}
onSubmit(event);
}}
>
<div>
<h1 className="text-2xl font-bold mb-2 text-center text-gray-800">Pickup Config Login</h1>
<p className="text-sm text-gray-500 text-center">
Die Anwendung authentifiziert sich direkt bei foodsharing.de. Bitte verwende deine persönlichen Login-Daten.
</p>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<span className="block sm:inline">{error}</span>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="email">
E-Mail
</label>
<input
id="email"
type="email"
name="email"
value={credentials.email}
onChange={handleChange('email')}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="password">
Passwort
</label>
<input
id="password"
type="password"
name="password"
value={credentials.password}
onChange={handleChange('password')}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<button
type="submit"
disabled={loading || initializing}
className="w-full bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
>
{loading || initializing ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
</div>
);
};
export default LoginView;

View File

@@ -0,0 +1,49 @@
import { Link, useLocation, useNavigate } from 'react-router-dom';
const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
const location = useLocation();
const navigate = useNavigate();
if (!isAdmin) {
return null;
}
const tabs = [
{ to: '/', label: 'Konfiguration' },
{ to: '/admin', label: 'Admin' }
];
const handleClick = (event, to) => {
event.preventDefault();
if (to === location.pathname) {
return;
}
onProtectedNavigate(`zur Seite "${tabs.find((tab) => tab.to === to)?.label || ''}" zu wechseln`, () =>
navigate(to)
);
};
return (
<nav className="mb-4 flex gap-2" aria-label="Navigation">
{tabs.map((tab) => {
const isActive = location.pathname === tab.to;
return (
<Link
key={tab.to}
to={tab.to}
onClick={(event) => handleClick(event, tab.to)}
className={`px-4 py-2 rounded-md border transition-colors ${
isActive
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
}`}
>
{tab.label}
</Link>
);
})}
</nav>
);
};
export default NavigationTabs;

View File

@@ -0,0 +1,196 @@
const NotificationPanel = ({
error,
message,
settings,
capabilities,
loading,
dirty,
saving,
onReset,
onSave,
onFieldChange,
onSendTest,
onCopyLink,
copyFeedback,
ntfyPreviewUrl
}) => {
return (
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
{error && (
<div className="mb-4 bg-red-100 border border-red-300 text-red-700 px-4 py-2 rounded">
{error}
</div>
)}
{message && (
<div className="mb-4 bg-green-100 border border-green-300 text-green-700 px-4 py-2 rounded">
{message}
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-gray-800">ntfy</h3>
<p className="text-xs text-gray-500">
Push aufs Handy über die ntfy-App oder Browser (Themenkanal notwendig).
</p>
</div>
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
<input
type="checkbox"
checked={settings.ntfy.enabled}
onChange={(event) => onFieldChange('ntfy', 'enabled', event.target.checked)}
disabled={!capabilities.ntfy.enabled}
className="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span>Aktiv</span>
</label>
</div>
{!capabilities.ntfy.enabled ? (
<p className="text-sm text-gray-500">
Diese Option wurde vom Admin deaktiviert. Bitte frage nach, wenn du ntfy nutzen möchtest.
</p>
) : (
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Topic / Kanal</label>
<input
type="text"
value={settings.ntfy.topic}
onChange={(event) => onFieldChange('ntfy', 'topic', event.target.value)}
placeholder="z. B. mein-pickup-topic"
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={!settings.ntfy.enabled}
/>
</div>
{capabilities.ntfy.serverUrl && (
<p className="text-xs text-gray-500">
Server: {capabilities.ntfy.serverUrl} (vom Admin festgelegt)
</p>
)}
{capabilities.ntfy.topicPrefix && (
<p className="text-xs text-gray-500">
Präfix: {capabilities.ntfy.topicPrefix} (Bindestrich wird automatisch ergänzt)
</p>
)}
{ntfyPreviewUrl && (
<div className="flex items-center gap-2 text-xs text-gray-600">
<a
href={ntfyPreviewUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 break-all"
>
{ntfyPreviewUrl}
</a>
<button
type="button"
onClick={() => onCopyLink(ntfyPreviewUrl)}
className="border border-gray-300 rounded px-2 py-0.5 text-gray-600 hover:text-blue-700 hover:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-400"
title="In Zwischenablage kopieren"
>
Kopieren
</button>
</div>
)}
{copyFeedback && <p className="text-xs text-green-600">{copyFeedback}</p>}
<button
type="button"
onClick={() => onSendTest('ntfy')}
disabled={!settings.ntfy.enabled || saving}
className="text-sm text-blue-600 hover:text-blue-800"
>
Test senden
</button>
</div>
)}
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-gray-800">Telegram</h3>
<p className="text-xs text-gray-500">
Nutze den Bot des Admins, um Nachrichten direkt in Telegram zu erhalten.
</p>
</div>
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
<input
type="checkbox"
checked={settings.telegram.enabled}
onChange={(event) => onFieldChange('telegram', 'enabled', event.target.checked)}
disabled={!capabilities.telegram.enabled}
className="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span>Aktiv</span>
</label>
</div>
{!capabilities.telegram.enabled ? (
<p className="text-sm text-gray-500">
Telegram-Benachrichtigungen sind derzeit deaktiviert oder der Bot ist nicht konfiguriert.
</p>
) : (
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Chat-ID</label>
<input
type="text"
value={settings.telegram.chatId}
onChange={(event) => onFieldChange('telegram', 'chatId', event.target.value)}
placeholder="z. B. 123456789"
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={!settings.telegram.enabled}
/>
<p className="text-xs text-gray-500 mt-1">
Tipp: Schreibe dem Telegram-Bot und nutze{' '}
<a
href="https://t.me/userinfobot"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
@userinfobot
</a>{' '}
oder ein Chat-ID-Tool.
</p>
</div>
<button
type="button"
onClick={() => onSendTest('telegram')}
disabled={!settings.telegram.enabled || saving}
className="text-sm text-blue-600 hover:text-blue-800"
>
Test senden
</button>
</div>
)}
</div>
</div>
<div className="flex items-center justify-end mt-4 gap-3">
<button
type="button"
onClick={onReset}
className="text-sm text-gray-600 hover:text-gray-900"
disabled={loading}
>
Zurücksetzen
</button>
<button
type="button"
onClick={onSave}
disabled={!dirty || saving}
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors disabled:opacity-60"
>
{saving ? 'Speichere...' : 'Benachrichtigungen speichern'}
</button>
</div>
</div>
);
};
export default NotificationPanel;

View File

@@ -0,0 +1,45 @@
const formatEta = (seconds) => {
if (seconds == null || seconds === Infinity) {
return null;
}
const clamped = Math.max(0, seconds);
const mins = Math.floor(clamped / 60);
const secs = clamped % 60;
if (mins > 0) {
return `${mins}m ${secs.toString().padStart(2, '0')}s`;
}
return `${secs}s`;
};
const StoreSyncOverlay = ({ state }) => {
if (!state?.active) {
return null;
}
const percent = Math.round(state.percent || 0);
const backgroundColor = state.block ? 'rgba(255,255,255,0.95)' : 'rgba(15,23,42,0.4)';
const etaLabel = formatEta(state.etaSeconds);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center px-4" style={{ backgroundColor }}>
<div className="bg-white shadow-2xl rounded-lg p-6 w-full max-w-md">
<p className="text-gray-800 font-semibold mb-3">{state.message || 'Synchronisiere...'}</p>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="h-3 bg-blue-600 transition-all duration-300 ease-out"
style={{ width: `${percent}%` }}
/>
</div>
<div className="flex items-center justify-between mt-2 text-sm text-gray-500">
<span>{percent}%</span>
<span>{etaLabel ? `ETA ~ ${etaLabel}` : 'Bitte warten...'}</span>
</div>
<p className="text-xs text-gray-400 mt-2">
Die Verzögerung schützt vor Rate-Limits während die Betriebe geprüft werden.
</p>
</div>
</div>
);
};
export default StoreSyncOverlay;