feat: add Shoutrrr push notification support and settings to reminders
This commit is contained in:
@@ -12,12 +12,18 @@ type SettingsBody = {
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string;
|
||||
};
|
||||
|
||||
type TestEmailBody = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
type TestShoutrrrBody = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
// Notification settings are stored in a JSON file (user-configurable)
|
||||
// SMTP settings come from .env (admin-configured)
|
||||
const notificationSettingsFile = resolve(process.cwd(), "data", "notification-settings.json");
|
||||
@@ -30,6 +36,8 @@ type NotificationSettings = {
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string;
|
||||
};
|
||||
|
||||
function loadNotificationSettings(): NotificationSettings {
|
||||
@@ -44,18 +52,23 @@ function loadNotificationSettings(): NotificationSettings {
|
||||
lowStockDays: saved.lowStockDays ?? 30,
|
||||
normalStockDays: saved.normalStockDays ?? 90,
|
||||
highStockDays: saved.highStockDays ?? 180,
|
||||
shoutrrrEnabled: saved.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: saved.shoutrrrUrl ?? "",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
|
||||
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, shoutrrrEnabled: false, shoutrrrUrl: "" };
|
||||
}
|
||||
|
||||
function saveNotificationSettings(settings: NotificationSettings): void {
|
||||
writeFileSync(notificationSettingsFile, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
|
||||
// Export for use in reminder scheduler
|
||||
export { loadNotificationSettings };
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
// Get settings - notification from JSON file, SMTP from process.env
|
||||
app.get("/settings", async (_request, reply) => {
|
||||
@@ -71,6 +84,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
lowStockDays: notification.lowStockDays,
|
||||
normalStockDays: notification.normalStockDays,
|
||||
highStockDays: notification.highStockDays,
|
||||
shoutrrrEnabled: notification.shoutrrrEnabled,
|
||||
shoutrrrUrl: notification.shoutrrrUrl,
|
||||
// SMTP settings (admin-configured, from .env)
|
||||
smtpHost: process.env.SMTP_HOST ?? "",
|
||||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
|
||||
@@ -97,6 +112,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
lowStockDays: body.lowStockDays ?? 30,
|
||||
normalStockDays: body.normalStockDays ?? 90,
|
||||
highStockDays: body.highStockDays ?? 180,
|
||||
shoutrrrEnabled: body.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: body.shoutrrrUrl ?? "",
|
||||
});
|
||||
|
||||
return reply.send({ success: true });
|
||||
@@ -150,4 +167,79 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Test Shoutrrr/ntfy notification
|
||||
app.post<{ Body: TestShoutrrrBody }>("/settings/test-shoutrrr", async (request, reply) => {
|
||||
const { url } = request.body;
|
||||
|
||||
if (!url) {
|
||||
return reply.status(400).send({ error: "Notification URL is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendShoutrrrNotification(url, "MedAssist Test", "This is a test notification from MedAssist. If you received this, your notification configuration is working correctly!");
|
||||
|
||||
if (result.success) {
|
||||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||||
} else {
|
||||
return reply.status(500).send({ error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
|
||||
export async function sendShoutrrrNotification(urlStr: string, title: string, message: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Parse the URL to determine the service
|
||||
let targetUrl: string;
|
||||
let method = "POST";
|
||||
let headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
|
||||
// Handle different URL formats
|
||||
if (urlStr.startsWith("ntfy://")) {
|
||||
// ntfy://[user:pass@]host/topic -> https://host/topic
|
||||
const parsed = new URL(urlStr.replace("ntfy://", "https://"));
|
||||
targetUrl = `https://${parsed.host}${parsed.pathname}`;
|
||||
headers = { "Title": title };
|
||||
body = message;
|
||||
|
||||
// Handle basic auth if present
|
||||
if (parsed.username && parsed.password) {
|
||||
headers["Authorization"] = "Basic " + Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
|
||||
}
|
||||
} else if (urlStr.startsWith("https://ntfy.") || urlStr.includes("ntfy.sh") || urlStr.includes("/ntfy/")) {
|
||||
// Direct ntfy HTTPS URL
|
||||
targetUrl = urlStr;
|
||||
headers = { "Title": title };
|
||||
body = message;
|
||||
} else if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
|
||||
// Generic webhook URL - send as JSON
|
||||
targetUrl = urlStr;
|
||||
headers = { "Content-Type": "application/json" };
|
||||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
||||
} else {
|
||||
return { success: false, error: "Unsupported URL format. Use ntfy:// or https:// URL" };
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from "../db/client.js";
|
||||
import { medications } from "../db/schema.js";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js";
|
||||
|
||||
type Slice = { usage: number; every: number; start: string };
|
||||
|
||||
@@ -14,6 +15,8 @@ type NotificationSettings = {
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string;
|
||||
};
|
||||
|
||||
type ReminderState = {
|
||||
@@ -43,29 +46,8 @@ function getMsUntilNextCheck(): number {
|
||||
return next.getTime() - Date.now();
|
||||
}
|
||||
|
||||
const notificationSettingsFile = resolve(process.cwd(), "data", "notification-settings.json");
|
||||
const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json");
|
||||
|
||||
function loadNotificationSettings(): NotificationSettings {
|
||||
try {
|
||||
if (existsSync(notificationSettingsFile)) {
|
||||
const saved = JSON.parse(readFileSync(notificationSettingsFile, "utf-8"));
|
||||
return {
|
||||
emailEnabled: saved.emailEnabled ?? false,
|
||||
notificationEmail: saved.notificationEmail ?? "",
|
||||
reminderDaysBefore: saved.reminderDaysBefore ?? 7,
|
||||
repeatDailyReminders: saved.repeatDailyReminders ?? false,
|
||||
lowStockDays: saved.lowStockDays ?? 30,
|
||||
normalStockDays: saved.normalStockDays ?? 90,
|
||||
highStockDays: saved.highStockDays ?? 180,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
|
||||
}
|
||||
|
||||
function loadReminderState(): ReminderState {
|
||||
try {
|
||||
if (existsSync(reminderStateFile)) {
|
||||
@@ -265,9 +247,12 @@ Automatic reminder from MedAssist`;
|
||||
async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
|
||||
const settings = loadNotificationSettings();
|
||||
|
||||
// Check if email reminders are enabled
|
||||
if (!settings.emailEnabled || !settings.notificationEmail) {
|
||||
logger.info("[Reminder] Email reminders disabled or no email configured");
|
||||
// Check if any notifications are enabled
|
||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail;
|
||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl;
|
||||
|
||||
if (!emailEnabled && !shoutrrrEnabled) {
|
||||
logger.info("[Reminder] No notifications enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -320,10 +305,38 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
|
||||
|
||||
logger.info(`[Reminder] Sending reminder for ${medsToNotify.length} medications...`);
|
||||
|
||||
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify);
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
|
||||
if (result.success) {
|
||||
// Update state (preserve nextScheduledCheck)
|
||||
// Send email if enabled
|
||||
if (emailEnabled) {
|
||||
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify);
|
||||
emailSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`);
|
||||
} else {
|
||||
logger.error(`[Reminder] Failed to send email: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send Shoutrrr notification if enabled
|
||||
if (shoutrrrEnabled) {
|
||||
const title = `⚠️ MedAssist: ${medsToNotify.length} Medication${medsToNotify.length > 1 ? "s" : ""} Running Low`;
|
||||
const message = medsToNotify
|
||||
.map((m) => `• ${m.name}: ${m.medsLeft} pills, ${m.daysLeft ?? 0} days left`)
|
||||
.join("\n");
|
||||
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[Reminder] Push notification sent successfully`);
|
||||
} else {
|
||||
logger.error(`[Reminder] Failed to send push notification: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state if any notification was sent successfully
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
@@ -331,9 +344,6 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
|
||||
notifiedMedications: [...new Set([...stillLowStock, ...medsToNotify.map((m) => m.name)])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
});
|
||||
logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`);
|
||||
} else {
|
||||
logger.error(`[Reminder] Failed to send email: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+227
-112
@@ -101,6 +101,9 @@ export default function App() {
|
||||
hasSmtpPassword: false,
|
||||
lastAutoEmailSent: null as string | null,
|
||||
nextScheduledCheck: null as string | null,
|
||||
// Shoutrrr/ntfy settings
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
});
|
||||
const [savedSettings, setSavedSettings] = useState(settings);
|
||||
const [settingsLoading, setSettingsLoading] = useState(false);
|
||||
@@ -108,6 +111,8 @@ export default function App() {
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [testingEmail, setTestingEmail] = useState(false);
|
||||
const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [testingShoutrrr, setTestingShoutrrr] = useState(false);
|
||||
const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false);
|
||||
const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [sendingReminderEmail, setSendingReminderEmail] = useState(false);
|
||||
@@ -174,7 +179,9 @@ export default function App() {
|
||||
settings.repeatDailyReminders !== savedSettings.repeatDailyReminders ||
|
||||
settings.lowStockDays !== savedSettings.lowStockDays ||
|
||||
settings.normalStockDays !== savedSettings.normalStockDays ||
|
||||
settings.highStockDays !== savedSettings.highStockDays;
|
||||
settings.highStockDays !== savedSettings.highStockDays ||
|
||||
settings.shoutrrrEnabled !== savedSettings.shoutrrrEnabled ||
|
||||
settings.shoutrrrUrl !== savedSettings.shoutrrrUrl;
|
||||
|
||||
const schedule = useMemo(() => buildSchedulePreview(meds), [meds]);
|
||||
const totalTablets = useMemo(() => deriveTotal(form), [form]);
|
||||
@@ -236,6 +243,8 @@ export default function App() {
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
smtpHost: settings.smtpHost,
|
||||
smtpPort: settings.smtpPort,
|
||||
smtpUser: settings.smtpUser,
|
||||
@@ -278,6 +287,29 @@ export default function App() {
|
||||
setTestingEmail(false);
|
||||
}
|
||||
|
||||
async function testShoutrrr() {
|
||||
if (!settings.shoutrrrUrl) return;
|
||||
setTestingShoutrrr(true);
|
||||
setTestShoutrrrResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/test-shoutrrr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: settings.shoutrrrUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setTestShoutrrrResult({ success: true, message: data.message || "Notification sent!" });
|
||||
} else {
|
||||
setTestShoutrrrResult({ success: false, message: data.error || "Failed to send" });
|
||||
}
|
||||
} catch {
|
||||
setTestShoutrrrResult({ success: false, message: "Network error" });
|
||||
}
|
||||
setTestingShoutrrr(false);
|
||||
}
|
||||
|
||||
async function sendPlannerEmail() {
|
||||
if (!settings.notificationEmail || plannerRows.length === 0) return;
|
||||
setSendingPlannerEmail(true);
|
||||
@@ -475,9 +507,9 @@ export default function App() {
|
||||
<button className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"} onClick={() => navigate("/dashboard")}>Dashboard</button>
|
||||
<button className={currentPath === "/medications" ? "pill primary" : "pill"} onClick={() => navigate("/medications")}>Medications</button>
|
||||
<button className={currentPath === "/planner" ? "pill primary" : "pill"} onClick={() => navigate("/planner")}>Planner</button>
|
||||
<button className={currentPath === "/settings" ? "pill primary" : "pill"} onClick={() => navigate("/settings")}>⚙️</button>
|
||||
</div>
|
||||
<button className="theme-toggle" onClick={toggleTheme} title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}>
|
||||
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title="Settings">⚙️</button>
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -860,80 +892,209 @@ export default function App() {
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Automatic Email Reminders</h2>
|
||||
<span className="pill neutral">Daily check</span>
|
||||
<h2>Automatic Reminders</h2>
|
||||
<span className="pill neutral">Daily check at 6:00 AM</span>
|
||||
</div>
|
||||
{settingsLoading ? (
|
||||
<p>Loading settings...</p>
|
||||
) : (
|
||||
<form className="settings-form" onSubmit={saveSettings}>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<label className="setting-label">Enable Automatic Reminders</label>
|
||||
<p className="setting-desc">Automatically send email when medications are running low</p>
|
||||
<div className="setting-info-box">
|
||||
<p>🤖 <strong>How it works:</strong> The server checks daily at 6:00 AM. When a medication drops below the threshold, you get notified via <strong>all enabled channels</strong> below.</p>
|
||||
<div className="enabled-channels" style={{ marginTop: "0.5rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`pill clickable ${settings.emailEnabled ? "success" : "neutral"}`}
|
||||
onClick={async () => {
|
||||
const newSettings = { ...settings, emailEnabled: !settings.emailEnabled };
|
||||
setSettings(newSettings);
|
||||
try {
|
||||
await fetch("/api/settings/notifications", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newSettings),
|
||||
});
|
||||
} catch (e) { console.error(e); }
|
||||
}}
|
||||
style={{ cursor: "pointer", border: "none" }}
|
||||
>
|
||||
📧 Email: {settings.emailEnabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`pill clickable ${settings.shoutrrrEnabled ? "success" : "neutral"}`}
|
||||
onClick={async () => {
|
||||
const newSettings = { ...settings, shoutrrrEnabled: !settings.shoutrrrEnabled };
|
||||
setSettings(newSettings);
|
||||
try {
|
||||
await fetch("/api/settings/notifications", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newSettings),
|
||||
});
|
||||
} catch (e) { console.error(e); }
|
||||
}}
|
||||
style={{ cursor: "pointer", border: "none" }}
|
||||
>
|
||||
🔔 Push: {settings.shoutrrrEnabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, emailEnabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.emailEnabled && (
|
||||
<>
|
||||
<div className="setting-info-box">
|
||||
<p>🤖 <strong>How it works:</strong> The server checks daily at 6:00 AM. When a medication drops below the threshold, you get an email.</p>
|
||||
<div className="setting-section">
|
||||
<h3>⚙️ Reminder Settings</h3>
|
||||
<p className="setting-hint">These settings apply to both Email and Push notifications.</p>
|
||||
<div className="setting-group">
|
||||
<label>
|
||||
Reminder threshold (days)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="90"
|
||||
value={settings.reminderDaysBefore}
|
||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
<span className="input-hint">Send reminder when stock lasts less than this</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<label className="setting-label">Repeat daily reminders</label>
|
||||
<p className="setting-desc">Send daily notifications while stock is low (otherwise only once per medication)</p>
|
||||
</div>
|
||||
<div className="setting-group">
|
||||
<label>
|
||||
Send reminder to
|
||||
<input
|
||||
type="email"
|
||||
value={settings.notificationEmail}
|
||||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
When stock lasts less than (days)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="90"
|
||||
value={settings.reminderDaysBefore}
|
||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<label className="setting-label">Repeat daily reminders</label>
|
||||
<p className="setting-desc">Send daily emails while stock is low (otherwise only once per medication)</p>
|
||||
</div>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.repeatDailyReminders}
|
||||
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-info-box">
|
||||
<p>⏰ <strong>Next automatic check:</strong> {settings.nextScheduledCheck ? new Date(settings.nextScheduledCheck).toLocaleString() : "—"}</p>
|
||||
{settings.lastAutoEmailSent && (
|
||||
<p style={{ marginTop: "0.5rem" }}>✓ Last automatic email: <strong>{new Date(settings.lastAutoEmailSent).toLocaleString()}</strong></p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.repeatDailyReminders}
|
||||
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-info-box">
|
||||
<p>⏰ <strong>Next automatic check:</strong> {settings.nextScheduledCheck ? new Date(settings.nextScheduledCheck).toLocaleString() : "—"}</p>
|
||||
{settings.lastAutoEmailSent && (
|
||||
<p style={{ marginTop: "0.5rem" }}>✓ Last notification sent: <strong>{new Date(settings.lastAutoEmailSent).toLocaleString()}</strong></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>Stock Thresholds</h3>
|
||||
<p className="setting-hint">Define stock levels based on how many days of medication you have left.</p>
|
||||
<h3>📧 Email Notifications</h3>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<label className="setting-label">Enable Email Notifications</label>
|
||||
<p className="setting-desc">Receive reminders via email</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, emailEnabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.emailEnabled && (
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
Email address
|
||||
<input
|
||||
type="email"
|
||||
value={settings.notificationEmail}
|
||||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="smtp-readonly">
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">SMTP Host</span>
|
||||
<span className="smtp-value">{settings.smtpHost || "—"}</span>
|
||||
</div>
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">Port</span>
|
||||
<span className="smtp-value">{settings.smtpPort}</span>
|
||||
</div>
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">From</span>
|
||||
<span className="smtp-value">{settings.smtpFrom || "—"}</span>
|
||||
</div>
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">Status</span>
|
||||
<span className="smtp-value">{settings.hasSmtpPassword ? "✓ Configured" : "Not configured"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="setting-hint" style={{ marginTop: "0.5rem" }}>SMTP is configured via <code>.env</code> file</p>
|
||||
<div className="setting-actions">
|
||||
<button type="button" className="ghost" onClick={testEmail} disabled={testingEmail || !settings.notificationEmail}>
|
||||
{testingEmail ? "Sending..." : "Send Test Email"}
|
||||
</button>
|
||||
{testEmailResult && (
|
||||
<span className={testEmailResult.success ? "success-text" : "danger-text"}>
|
||||
{testEmailResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>🔔 Shoutrrr Push Notifications</h3>
|
||||
<p className="setting-hint">Send push notifications via Shoutrrr-compatible services (ntfy, Discord, Telegram, Slack, etc.). Uses the same reminder threshold.</p>
|
||||
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<label className="setting-label">Enable Shoutrrr Notifications</label>
|
||||
<p className="setting-desc">Receive reminders via push notification services</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrEnabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.shoutrrrEnabled && (
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
Notification URL
|
||||
<input
|
||||
type="url"
|
||||
value={settings.shoutrrrUrl}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||||
placeholder="https://ntfy.sh/your-topic"
|
||||
pattern="(https?|ntfy|discord|telegram|slack):\/\/.+"
|
||||
/>
|
||||
<span className="input-hint">
|
||||
Examples: <code>https://ntfy.sh/mytopic</code> · <code>ntfy://ntfy.sh/mytopic</code> · <code>discord://token@id</code>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<button type="button" className="ghost" onClick={testShoutrrr} disabled={testingShoutrrr || !settings.shoutrrrUrl}>
|
||||
{testingShoutrrr ? "Sending..." : "Send Test Notification"}
|
||||
</button>
|
||||
{testShoutrrrResult && (
|
||||
<span className={testShoutrrrResult.success ? "success-text" : "danger-text"}>
|
||||
{testShoutrrrResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>📊 Stock Thresholds</h3>
|
||||
<p className="setting-hint">Define stock level colors based on how many days of medication you have left.</p>
|
||||
<div className="setting-group">
|
||||
<label>
|
||||
Low Stock (days)
|
||||
@@ -960,52 +1121,6 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.emailEnabled && (
|
||||
<>
|
||||
<div className="setting-section">
|
||||
<h3>SMTP Configuration</h3>
|
||||
<p className="setting-hint">These settings are configured in the <code>.env</code> file.</p>
|
||||
<div className="smtp-readonly">
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">Host</span>
|
||||
<span className="smtp-value">{settings.smtpHost || "—"}</span>
|
||||
</div>
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">Port</span>
|
||||
<span className="smtp-value">{settings.smtpPort}</span>
|
||||
</div>
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">User</span>
|
||||
<span className="smtp-value">{settings.smtpUser || "—"}</span>
|
||||
</div>
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">Password</span>
|
||||
<span className="smtp-value">{settings.hasSmtpPassword ? "••••••••" : "—"}</span>
|
||||
</div>
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">From</span>
|
||||
<span className="smtp-value">{settings.smtpFrom || "—"}</span>
|
||||
</div>
|
||||
<div className="smtp-field">
|
||||
<span className="smtp-label">SSL/TLS</span>
|
||||
<span className="smtp-value">{settings.smtpSecure ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-actions">
|
||||
<button type="button" className="ghost" onClick={testEmail} disabled={testingEmail || !settings.notificationEmail}>
|
||||
{testingEmail ? "Sending..." : "Send Test Email"}
|
||||
</button>
|
||||
{testEmailResult && (
|
||||
<span className={testEmailResult.success ? "success-text" : "danger-text"}>
|
||||
{testEmailResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-footer">
|
||||
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
|
||||
{settingsSaving ? "Saving..." : settingsSaved && !settingsChanged ? "Saved ✓" : "Save Settings"}
|
||||
|
||||
+17
-7
@@ -82,26 +82,34 @@ body {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(59, 130, 246, 0.04));
|
||||
}
|
||||
|
||||
.header-actions { display: flex; align-items: center; gap: 1rem; }
|
||||
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.theme-toggle {
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid var(--border-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 150ms ease, background 150ms ease;
|
||||
padding: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
.icon-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background: rgba(47, 134, 246, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
opacity: 1;
|
||||
}
|
||||
.icon-btn.active {
|
||||
opacity: 1;
|
||||
background: rgba(47, 134, 246, 0.15);
|
||||
}
|
||||
[data-theme="light"] .icon-btn:hover { background: rgba(0, 0, 0, 0.08); }
|
||||
[data-theme="light"] .icon-btn.active { background: rgba(47, 134, 246, 0.12); }
|
||||
|
||||
.hero h1 { margin: 0.15rem 0 0; font-size: 1.6rem; font-weight: 600; }
|
||||
.sub { color: var(--text-secondary); margin: 0; }
|
||||
@@ -177,6 +185,8 @@ body {
|
||||
.card h2 { margin: 0; font-size: 1.2rem; }
|
||||
|
||||
.pill { border: 1px solid var(--accent); color: var(--text-muted); background: var(--accent-bg); padding: 0.35rem 0.7rem; border-radius: 999px; font-size: 0.85rem; transition: all 150ms ease; }
|
||||
.pill.clickable { cursor: pointer; }
|
||||
.pill.clickable:hover { filter: brightness(1.15); transform: scale(1.02); }
|
||||
.pill.success { border-color: var(--success); background: var(--success-bg); color: var(--success); }
|
||||
.pill.neutral { border-color: var(--border-secondary); background: rgba(255, 255, 255, 0.04); color: var(--text-muted); }
|
||||
[data-theme=\"light\"] .pill.neutral { background: rgba(0, 0, 0, 0.04); }
|
||||
|
||||
Reference in New Issue
Block a user