feat: implement push notification support for low stock reminders and enhance email validation
This commit is contained in:
+123
-80
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { updateReminderSentTime } from "../services/reminder-scheduler.js";
|
||||
import { loadNotificationSettings, sendShoutrrrNotification } from "./settings.js";
|
||||
|
||||
type PlannerRow = {
|
||||
medicationId: number;
|
||||
@@ -169,74 +170,76 @@ Sent from MedAssist Medication Planner`;
|
||||
}
|
||||
});
|
||||
|
||||
// Reminder email for low stock medications
|
||||
// Reminder notification for low stock medications (supports email and push)
|
||||
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
|
||||
const { email, lowStock } = request.body;
|
||||
|
||||
if (!email || !lowStock || lowStock.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing email or low stock data" });
|
||||
if (!lowStock || lowStock.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing low stock data" });
|
||||
}
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
const notificationSettings = loadNotificationSettings();
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
}
|
||||
// Send email if enabled
|
||||
if (notificationSettings.emailEnabled && email) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
const tableRows = lowStock
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.depletionDate ?? "-"}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
if (smtpHost && smtpUser) {
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
const tableRows = lowStock
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.depletionDate ?? "-"}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">⚠️ MedAssist - Reorder Reminder</h2>
|
||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">The following medications are running low and need to be reordered:</p>
|
||||
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #fecaca;">
|
||||
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
|
||||
⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low!
|
||||
</p>
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">⚠️ MedAssist - Reorder Reminder</h2>
|
||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">The following medications are running low and need to be reordered:</p>
|
||||
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #fecaca;">
|
||||
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
|
||||
⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
|
||||
<thead>
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Medication</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Pills</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Days</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Runs Out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist Medication Planner</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
|
||||
<thead>
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Medication</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Pills</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Days</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Runs Out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist Medication Planner</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const plainText = `MedAssist - Reorder Reminder
|
||||
const plainText = `MedAssist - Reorder Reminder
|
||||
|
||||
The following medications are running low:
|
||||
|
||||
@@ -245,32 +248,72 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} d
|
||||
---
|
||||
Sent from MedAssist Medication Planner`;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `⚠️ MedAssist - ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `⚠️ MedAssist - ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
|
||||
// Update the reminder state to record this email was sent
|
||||
results.email = true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
const title = `${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`;
|
||||
const message = lowStock
|
||||
.map((r) => `- ${r.name}: ${r.medsLeft} pills (${r.daysLeft ?? 0} days)`)
|
||||
.join("\n");
|
||||
|
||||
try {
|
||||
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message);
|
||||
if (pushResult.success) {
|
||||
results.push = true;
|
||||
} else {
|
||||
results.errors.push(`Push: ${pushResult.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Push: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the reminder state to record this notification was sent
|
||||
if (results.email || results.push) {
|
||||
updateReminderSentTime();
|
||||
}
|
||||
|
||||
return reply.send({ success: true, message: "Reminder email sent" });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||||
// Build response message
|
||||
const sentChannels: string[] = [];
|
||||
if (results.email) sentChannels.push("email");
|
||||
if (results.push) sentChannels.push("push");
|
||||
|
||||
if (sentChannels.length > 0) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: `Reminder sent via ${sentChannels.join(" and ")}`
|
||||
});
|
||||
} else if (results.errors.length > 0) {
|
||||
return reply.status(500).send({ error: results.errors.join("; ") });
|
||||
} else {
|
||||
return reply.status(400).send({ error: "No notification channels configured" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,12 +200,15 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me
|
||||
let headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
|
||||
// Remove emojis from title for header compatibility (ntfy doesn't support unicode in headers)
|
||||
const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|⚠️/gu, "").trim();
|
||||
|
||||
// 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 };
|
||||
headers = { "Title": cleanTitle, "Tags": "warning" };
|
||||
body = message;
|
||||
|
||||
// Handle basic auth if present
|
||||
@@ -215,7 +218,7 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me
|
||||
} else if (urlStr.startsWith("https://ntfy.") || urlStr.includes("ntfy.sh") || urlStr.includes("/ntfy/")) {
|
||||
// Direct ntfy HTTPS URL
|
||||
targetUrl = urlStr;
|
||||
headers = { "Title": title };
|
||||
headers = { "Title": cleanTitle, "Tags": "warning" };
|
||||
body = message;
|
||||
} else if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
|
||||
// Generic webhook URL - send as JSON
|
||||
|
||||
+65
-55
@@ -233,6 +233,16 @@ export default function App() {
|
||||
|
||||
async function saveSettings(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate email if email notifications are enabled
|
||||
if (settings.emailEnabled && settings.notificationEmail) {
|
||||
const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i;
|
||||
if (!emailRegex.test(settings.notificationEmail)) {
|
||||
setTestEmailResult({ success: false, message: "Invalid email address" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSettingsSaving(true);
|
||||
setTestEmailResult(null);
|
||||
|
||||
@@ -531,13 +541,13 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={
|
||||
<>
|
||||
{settings.emailEnabled && settings.notificationEmail && (
|
||||
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
|
||||
<section className="email-status-bar">
|
||||
<span className="email-status-icon">📧</span>
|
||||
<span className="email-status-icon">{settings.emailEnabled && settings.shoutrrrEnabled ? "🔔" : settings.emailEnabled ? "📧" : "🔔"}</span>
|
||||
<span className="email-status-text">
|
||||
Automatic reminders active — {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent)}
|
||||
</span>
|
||||
<span className="email-status-recipient">→ {settings.notificationEmail}</span>
|
||||
{settings.emailEnabled && settings.notificationEmail && <span className="email-status-recipient">→ {settings.notificationEmail}</span>}
|
||||
</section>
|
||||
)}
|
||||
<section className="grid">
|
||||
@@ -576,10 +586,10 @@ export default function App() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{settings.emailEnabled && settings.notificationEmail && (
|
||||
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
|
||||
<div className="email-send-action">
|
||||
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
|
||||
{sendingReminderEmail ? "Sending..." : "📧 Send Reminder Now"}
|
||||
{sendingReminderEmail ? "Sending..." : "🔔 Send Reminder Now"}
|
||||
</button>
|
||||
{reminderEmailResult && (
|
||||
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
|
||||
@@ -970,10 +980,44 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>📊 Stock Display</h3>
|
||||
</div>
|
||||
<div className="setting-group">
|
||||
<label>
|
||||
<span className="field-label">Low Stock (days)</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={settings.lowStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip="Yellow warning color when supply is below this threshold. Only affects display, not reminders.">ⓘ</span>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">High Stock (days)</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="730"
|
||||
value={settings.highStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip="Green with star when supply is above this threshold. Only affects display, not reminders.">ⓘ</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>⚙️ Reminder Threshold</h3>
|
||||
<span className="info-tooltip" title="Applies to both Email and Push notifications. When a medication's remaining supply falls below this threshold, you'll receive a notification.">ⓘ</span>
|
||||
<span className="info-tooltip" data-tooltip="Applies to both Email and Push notifications. When a medication's remaining supply falls below this threshold, you'll receive a notification.">ⓘ</span>
|
||||
</div>
|
||||
<div className="threshold-input">
|
||||
<label>
|
||||
@@ -993,7 +1037,7 @@ export default function App() {
|
||||
<div className="setting-row compact">
|
||||
<label className="setting-label">
|
||||
Repeat daily
|
||||
<span className="info-tooltip small" title="When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked.">ⓘ</span>
|
||||
<span className="info-tooltip small" data-tooltip="When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked.">ⓘ</span>
|
||||
</label>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
@@ -1019,6 +1063,9 @@ export default function App() {
|
||||
value={settings.notificationEmail}
|
||||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1027,7 +1074,7 @@ export default function App() {
|
||||
SMTP: {settings.smtpHost || "Not configured"}:{settings.smtpPort}
|
||||
{settings.hasSmtpPassword && " ✓"}
|
||||
</span>
|
||||
<span className="info-tooltip small" title={`Host: ${settings.smtpHost || "—"}\nPort: ${settings.smtpPort}\nFrom: ${settings.smtpFrom || "—"}\n\nConfigured via .env file`}>ⓘ</span>
|
||||
<span className="info-tooltip small" data-tooltip={`Host: ${settings.smtpHost || "—"}\nPort: ${settings.smtpPort}\nFrom: ${settings.smtpFrom || "—"}\n\nConfigured via .env file`}>ⓘ</span>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<button type="button" className="ghost" onClick={testEmail} disabled={testingEmail || !settings.notificationEmail}>
|
||||
@@ -1051,14 +1098,16 @@ export default function App() {
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
<span className="field-label">Notification URL</span>
|
||||
<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="field-examples">e.g. https://ntfy.sh/mytopic, discord://token@id</span>
|
||||
<div className="input-with-tooltip">
|
||||
<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="info-tooltip" data-tooltip="e.g. https://ntfy.sh/mytopic, discord://token@id">ⓘ</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
@@ -1074,45 +1123,6 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>📊 Stock Display</h3>
|
||||
<span className="info-tooltip" title="These thresholds control the color-coding in the medication overview. They don't affect when reminders are sent.">ⓘ</span>
|
||||
</div>
|
||||
<div className="threshold-grid">
|
||||
<label className="threshold-card warning">
|
||||
<span className="threshold-icon">⚠️</span>
|
||||
<span className="threshold-title">Low Stock</span>
|
||||
<div className="threshold-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={settings.lowStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||||
/>
|
||||
<span>days</span>
|
||||
</div>
|
||||
<span className="threshold-desc">Yellow warning</span>
|
||||
</label>
|
||||
<label className="threshold-card success">
|
||||
<span className="threshold-icon">✓</span>
|
||||
<span className="threshold-title">High Stock</span>
|
||||
<div className="threshold-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="730"
|
||||
value={settings.highStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||||
/>
|
||||
<span>days</span>
|
||||
</div>
|
||||
<span className="threshold-desc">Green with star</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-footer">
|
||||
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
|
||||
{settingsSaving ? "Saving..." : settingsSaved && !settingsChanged ? "Saved ✓" : "Save Settings"}
|
||||
|
||||
+27
-90
@@ -891,7 +891,7 @@ textarea {
|
||||
}
|
||||
|
||||
.info-tooltip::after {
|
||||
content: attr(title);
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
@@ -904,6 +904,8 @@ textarea {
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
text-align: left;
|
||||
@@ -1084,13 +1086,13 @@ textarea {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Field Label */
|
||||
/* Field Label with inline tooltip */
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -1103,6 +1105,25 @@ textarea {
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
/* Input with tooltip inside */
|
||||
.input-with-tooltip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-with-tooltip input {
|
||||
width: 100%;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.input-with-tooltip .info-tooltip {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* SMTP Info */
|
||||
.smtp-info {
|
||||
display: flex;
|
||||
@@ -1120,86 +1141,6 @@ textarea {
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
/* Threshold Cards */
|
||||
.threshold-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.threshold-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-input);
|
||||
border: 2px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.threshold-card:hover,
|
||||
.threshold-card:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.threshold-card.warning {
|
||||
border-color: rgba(252, 211, 77, 0.3);
|
||||
}
|
||||
|
||||
.threshold-card.warning:hover,
|
||||
.threshold-card.warning:focus-within {
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
|
||||
.threshold-card.success {
|
||||
border-color: rgba(57, 217, 138, 0.3);
|
||||
}
|
||||
|
||||
.threshold-card.success:hover,
|
||||
.threshold-card.success:focus-within {
|
||||
border-color: #39d98a;
|
||||
}
|
||||
|
||||
.threshold-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.threshold-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.threshold-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.threshold-input-wrap input {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
padding: 0.35rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.threshold-input-wrap span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.threshold-desc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.channels-overview {
|
||||
flex-direction: column;
|
||||
@@ -1215,10 +1156,6 @@ textarea {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.threshold-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-actions {
|
||||
|
||||
Reference in New Issue
Block a user