feat: add email notification settings and test email functionality
- Created a new migration to add email settings to the database. - Implemented routes for managing notification settings, including retrieving and updating settings. - Added functionality to send test emails using SMTP configuration from environment variables.
This commit is contained in:
Generated
+1391
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,12 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.32.2",
|
||||
"fastify": "^5.0.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/nodemailer": "^6.4.21",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add email notification settings to settings table
|
||||
ALTER TABLE settings ADD COLUMN email_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE settings ADD COLUMN notification_email TEXT;
|
||||
ALTER TABLE settings ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 7;
|
||||
@@ -2,6 +2,7 @@
|
||||
"entries": [
|
||||
{ "idx": 0, "version": 1, "when": 1734633120, "tag": "0000_init", "breakpoint": false },
|
||||
{ "idx": 1, "version": 1, "when": 1734633121, "tag": "0001_add_strips", "breakpoint": false },
|
||||
{ "idx": 2, "version": 1, "when": 1734633122, "tag": "0002_pack_inventory", "breakpoint": false }
|
||||
{ "idx": 2, "version": 1, "when": 1734633122, "tag": "0002_pack_inventory", "breakpoint": false },
|
||||
{ "idx": 3, "version": 1, "when": 1734710400, "tag": "0003_add_email_settings", "breakpoint": false }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,5 +45,9 @@ export const settings = sqliteTable("settings", {
|
||||
smtpFrom: text("smtp_from"),
|
||||
smtpSecure: integer("smtp_secure", { mode: "boolean" }).notNull().default(false),
|
||||
emailsPerDay: integer("emails_per_day").notNull().default(3),
|
||||
// Email notification settings
|
||||
emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
notificationEmail: text("notification_email"),
|
||||
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { env } from "./plugins/env.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -56,6 +57,7 @@ await app.register(jwt, { secret: env.JWT_SECRET, cookie: { cookieName: "access_
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
type SettingsBody = {
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
};
|
||||
|
||||
type TestEmailBody = {
|
||||
email: 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");
|
||||
|
||||
type NotificationSettings = {
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
};
|
||||
|
||||
function loadNotificationSettings(): NotificationSettings {
|
||||
try {
|
||||
if (existsSync(notificationSettingsFile)) {
|
||||
return JSON.parse(readFileSync(notificationSettingsFile, "utf-8"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7 };
|
||||
}
|
||||
|
||||
function saveNotificationSettings(settings: NotificationSettings): void {
|
||||
writeFileSync(notificationSettingsFile, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
// Get settings - notification from JSON file, SMTP from process.env
|
||||
app.get("/settings", async (_request, reply) => {
|
||||
const notification = loadNotificationSettings();
|
||||
|
||||
return reply.send({
|
||||
// Notification settings (user-configurable, stored in JSON)
|
||||
emailEnabled: notification.emailEnabled,
|
||||
notificationEmail: notification.notificationEmail,
|
||||
reminderDaysBefore: notification.reminderDaysBefore,
|
||||
// SMTP settings (admin-configured, from .env)
|
||||
smtpHost: process.env.SMTP_HOST ?? "",
|
||||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
|
||||
smtpUser: process.env.SMTP_USER ?? "",
|
||||
smtpFrom: process.env.SMTP_FROM ?? "",
|
||||
smtpSecure: process.env.SMTP_SECURE === "true",
|
||||
hasSmtpPassword: !!process.env.SMTP_PASS,
|
||||
});
|
||||
});
|
||||
|
||||
// Update settings - only notification settings are saved (SMTP comes from .env)
|
||||
app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => {
|
||||
const body = request.body;
|
||||
|
||||
// Save notification settings to JSON file
|
||||
saveNotificationSettings({
|
||||
emailEnabled: body.emailEnabled,
|
||||
notificationEmail: body.notificationEmail,
|
||||
reminderDaysBefore: body.reminderDaysBefore,
|
||||
});
|
||||
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
|
||||
// Test email - use SMTP settings from process.env
|
||||
app.post<{ Body: TestEmailBody }>("/settings/test-email", async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
|
||||
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;
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
}
|
||||
|
||||
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 - Test Email",
|
||||
text: "This is a test email from MedAssist. If you received this, your email configuration is working correctly!",
|
||||
html: `
|
||||
<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb;">MedAssist - Test Email</h2>
|
||||
<p>This is a test email from MedAssist.</p>
|
||||
<p style="color: #10b981; font-weight: 600;">✓ If you received this, your email configuration is working correctly!</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
|
||||
<p style="color: #6b7280; font-size: 14px;">Sent from MedAssist Medication Planner</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Test email sent successfully" });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
Generated
+90
@@ -10,11 +10,13 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^7.3.0"
|
||||
@@ -1162,6 +1164,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/history": {
|
||||
"version": "4.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1191,6 +1200,29 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router": {
|
||||
"version": "5.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
|
||||
"integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router-dom": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
|
||||
"integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*",
|
||||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1285,6 +1317,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1567,6 +1612,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -1585,6 +1631,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz",
|
||||
@@ -1646,6 +1730,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^7.3.0"
|
||||
|
||||
+449
-224
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Routes, Route, useNavigate, useLocation, Navigate } from "react-router-dom";
|
||||
|
||||
type Slice = {
|
||||
usage: number;
|
||||
@@ -70,7 +71,35 @@ export default function App() {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>(defaultForm());
|
||||
const [range, setRange] = useState<{ start: string; end: string }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
|
||||
const [view, setView] = useState<"dashboard" | "medications" | "planner">("dashboard");
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
|
||||
// Settings state
|
||||
const [settings, setSettings] = useState({
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
smtpHost: "",
|
||||
smtpPort: 587,
|
||||
smtpUser: "",
|
||||
smtpPass: "",
|
||||
smtpFrom: "",
|
||||
smtpSecure: false,
|
||||
hasSmtpPassword: false,
|
||||
});
|
||||
const [savedSettings, setSavedSettings] = useState(settings);
|
||||
const [settingsLoading, setSettingsLoading] = useState(false);
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [testingEmail, setTestingEmail] = useState(false);
|
||||
const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Check if settings have changed
|
||||
const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled ||
|
||||
settings.notificationEmail !== savedSettings.notificationEmail ||
|
||||
settings.reminderDaysBefore !== savedSettings.reminderDaysBefore;
|
||||
|
||||
const schedule = useMemo(() => buildSchedulePreview(meds), [meds]);
|
||||
const totalTablets = useMemo(() => deriveTotal(form), [form]);
|
||||
@@ -92,6 +121,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
loadMeds();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
function loadMeds() {
|
||||
@@ -103,6 +133,71 @@ export default function App() {
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
setSettingsLoading(true);
|
||||
fetch("/api/settings")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const newSettings = { ...settings, ...data, smtpPass: "" };
|
||||
setSettings(newSettings);
|
||||
setSavedSettings(newSettings);
|
||||
setSettingsSaved(false);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setSettingsLoading(false));
|
||||
}
|
||||
|
||||
async function saveSettings(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSettingsSaving(true);
|
||||
setTestEmailResult(null);
|
||||
|
||||
const payload = {
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
smtpHost: settings.smtpHost,
|
||||
smtpPort: settings.smtpPort,
|
||||
smtpUser: settings.smtpUser,
|
||||
smtpPass: settings.smtpPass || undefined,
|
||||
smtpFrom: settings.smtpFrom,
|
||||
smtpSecure: settings.smtpSecure,
|
||||
};
|
||||
|
||||
await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
|
||||
setSettingsSaving(false);
|
||||
setSavedSettings(settings);
|
||||
setSettingsSaved(true);
|
||||
}
|
||||
|
||||
async function testEmail() {
|
||||
if (!settings.notificationEmail) return;
|
||||
setTestingEmail(true);
|
||||
setTestEmailResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/test-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: settings.notificationEmail }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setTestEmailResult({ success: true, message: data.message || "Email sent!" });
|
||||
} else {
|
||||
setTestEmailResult({ success: false, message: data.error || "Failed to send" });
|
||||
}
|
||||
} catch {
|
||||
setTestEmailResult({ success: false, message: "Network error" });
|
||||
}
|
||||
setTestingEmail(false);
|
||||
}
|
||||
|
||||
async function deleteMed(id: number) {
|
||||
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (editingId === id) resetForm();
|
||||
@@ -186,6 +281,22 @@ export default function App() {
|
||||
setPlannerRows([]);
|
||||
}
|
||||
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
|
||||
}
|
||||
return "dark";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<header className="hero">
|
||||
@@ -193,263 +304,377 @@ export default function App() {
|
||||
<p className="eyebrow">Medassist · Planner</p>
|
||||
<h1>Manage medication plans</h1>
|
||||
</div>
|
||||
<div className="tabs">
|
||||
<button className={view === "dashboard" ? "pill primary" : "pill"} onClick={() => setView("dashboard")}>Dashboard</button>
|
||||
<button className={view === "medications" ? "pill primary" : "pill"} onClick={() => setView("medications")}>Medications</button>
|
||||
<button className={view === "planner" ? "pill primary" : "pill"} onClick={() => setView("planner")}>Planner</button>
|
||||
<div className="header-actions">
|
||||
<div className="tabs">
|
||||
<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"}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{view === "dashboard" && (
|
||||
<>
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Reorder Reminder</h2>
|
||||
<span className="pill neutral">Stock watch</span>
|
||||
</div>
|
||||
{coverage.low.length === 0 ? (
|
||||
<p className="success-text">All good, enough stock.</p>
|
||||
) : (
|
||||
<div className="table">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={
|
||||
<>
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Reorder Reminder</h2>
|
||||
<span className="pill neutral">Stock watch</span>
|
||||
</div>
|
||||
{coverage.low.length === 0 ? (
|
||||
<p className="success-text">All good, enough stock.</p>
|
||||
) : (
|
||||
<div className="table">
|
||||
<div className="table-head">
|
||||
<span>Name</span>
|
||||
<span>Current pills</span>
|
||||
<span>Days left</span>
|
||||
<span>Runs out</span>
|
||||
<span>Next dose</span>
|
||||
</div>
|
||||
{coverage.low.map((row) => (
|
||||
<div key={row.name} className="table-row">
|
||||
<span>{row.name}</span>
|
||||
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
|
||||
<span className={row.daysLeft !== null && row.daysLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.daysLeft)}</span>
|
||||
<span>{row.depletionDate ?? "-"}</span>
|
||||
<span>{row.nextDose ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Medication Overview</h2>
|
||||
<span className="pill neutral">Stock</span>
|
||||
</div>
|
||||
<div className="table table-4">
|
||||
<div className="table-head">
|
||||
<span>Name</span>
|
||||
<span>Current pills</span>
|
||||
<span>Days left</span>
|
||||
<span>Runs out</span>
|
||||
<span>Next dose</span>
|
||||
</div>
|
||||
{coverage.low.map((row) => (
|
||||
{coverage.all.map((row) => (
|
||||
<div key={row.name} className="table-row">
|
||||
<span>{row.name}</span>
|
||||
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
|
||||
<span className={row.daysLeft !== null && row.daysLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.daysLeft)}</span>
|
||||
<span>{formatNumber(row.daysLeft)}</span>
|
||||
<span>{row.depletionDate ?? "-"}</span>
|
||||
<span>{row.nextDose ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Medication Overview</h2>
|
||||
<span className="pill neutral">Stock</span>
|
||||
</div>
|
||||
<div className="table table-4">
|
||||
<div className="table-head">
|
||||
<span>Name</span>
|
||||
<span>Current pills</span>
|
||||
<span>Days left</span>
|
||||
<span>Runs out</span>
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Upcoming Schedules</h2>
|
||||
<span className="pill neutral">Next 10</span>
|
||||
</div>
|
||||
{coverage.all.map((row) => (
|
||||
<div key={row.name} className="table-row">
|
||||
<span>{row.name}</span>
|
||||
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
|
||||
<span>{formatNumber(row.daysLeft)}</span>
|
||||
<span>{row.depletionDate ?? "-"}</span>
|
||||
<div className="timeline">
|
||||
{groupedSchedule.map((day) => (
|
||||
<div key={day.dateStr} className="day-block">
|
||||
<div className="day-divider">{day.dateStr}</div>
|
||||
{day.meds.map((item) => {
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div className="time-main">
|
||||
<div className="med-name">{item.medName}</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} pills total</span>
|
||||
<span className={`tag ${outOfStock ? "danger" : "success"}`}>
|
||||
{outOfStock ? "⚠ No pills left" : "✓ Stock OK"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="time-col">
|
||||
<div className="time-chip times-chip">{item.times.join(" · ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
} />
|
||||
|
||||
<Route path="/medications" element={
|
||||
<section className="grid">
|
||||
<article className="card meds">
|
||||
<div className="card-head">
|
||||
<h2>Medication list</h2>
|
||||
<span className="pill neutral">{loading ? "Loading..." : `${meds.length} entries`}</span>
|
||||
</div>
|
||||
<div className="med-list">
|
||||
{meds.map((med) => (
|
||||
<div key={med.id} className="med-row">
|
||||
<div className="med-header">
|
||||
<div className="med-info">
|
||||
<div className="med-name">{med.name}</div>
|
||||
<div className="med-details">
|
||||
<span>Packs: <strong>{med.packCount ?? 1}</strong></span>
|
||||
<span>Blisters per pack: <strong>{med.stripsPerPack ?? med.strips ?? 1}</strong></span>
|
||||
<span>Pills per blister: <strong>{med.tabsPerStrip ?? med.stripSize}</strong></span>
|
||||
<span>Loose: <strong>{med.looseTablets ?? 0}</strong></span>
|
||||
</div>
|
||||
<div className="med-total">Total: {med.count} pills</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
<button className="ghost" onClick={() => startEdit(med)}>Edit</button>
|
||||
<button className="ghost danger" onClick={() => deleteMed(med.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="slice-list">
|
||||
{med.slices.map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="slice-row-simple">
|
||||
{s.usage} {s.usage === 1 ? "pill" : "pills"} · every {s.every} {s.every === 1 ? "day" : "days"} · from {formatDateTime(s.start)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<article className="card form">
|
||||
<div className="card-head">
|
||||
<h2>{editingId ? "Edit entry" : "New entry"}</h2>
|
||||
<span className="pill">Packs + loose pills</span>
|
||||
</div>
|
||||
<form className="form-grid" onSubmit={saveMedication}>
|
||||
<label>
|
||||
Name
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="z.B. Lisinopril" required />
|
||||
</label>
|
||||
<label>
|
||||
Packs
|
||||
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Blisters per pack
|
||||
<input type="number" min="1" value={form.stripsPerPack} onChange={(e) => handleValueChange("stripsPerPack", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Pills per blister
|
||||
<input type="number" min="1" value={form.tabsPerStrip} onChange={(e) => handleValueChange("tabsPerStrip", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Loose pills
|
||||
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Total (pills)
|
||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||
</label>
|
||||
|
||||
<div className="full slices">
|
||||
<div className="card-head">
|
||||
<h3>Intake schedule</h3>
|
||||
<button type="button" className="ghost" onClick={addSlice}>+ Intake</button>
|
||||
</div>
|
||||
{form.slices.map((s, idx) => (
|
||||
<div key={idx} className="slice-row">
|
||||
<div className="slice-inputs">
|
||||
<label>
|
||||
Usage (pills)
|
||||
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setSliceValue(idx, "usage", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Every (days)
|
||||
<input type="number" min="1" value={s.every} onChange={(e) => setSliceValue(idx, "every", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Start (date/time)
|
||||
<input type="datetime-local" step="60" value={s.start} onChange={(e) => setSliceValue(idx, "start", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
{form.slices.length > 1 && (
|
||||
<button type="button" className="ghost" onClick={() => removeSlice(idx)}>Remove</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="full align-end gap">
|
||||
{editingId && (
|
||||
<button type="button" className="ghost" onClick={resetForm}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={saving}>{saving ? "Saving..." : "Save"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
} />
|
||||
|
||||
<Route path="/planner" element={
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Upcoming Schedules</h2>
|
||||
<span className="pill neutral">Next 10</span>
|
||||
<h2>Demand Calculator</h2>
|
||||
<span className="pill neutral">Plan your supply</span>
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{groupedSchedule.map((day) => (
|
||||
<div key={day.dateStr} className="day-block">
|
||||
<div className="day-divider">{day.dateStr}</div>
|
||||
{day.meds.map((item) => {
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div className="time-main">
|
||||
<div className="med-name">{item.medName}</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} pills total</span>
|
||||
<span className={`tag ${outOfStock ? "danger" : "success"}`}>
|
||||
{outOfStock ? "⚠ No pills left" : "✓ Stock OK"}
|
||||
</span>
|
||||
<form className="planner" onSubmit={runPlanner}>
|
||||
<label>
|
||||
From
|
||||
<input type="datetime-local" step="60" value={range.start} onChange={(e) => setRange({ ...range, start: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Until
|
||||
<input type="datetime-local" step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||
</label>
|
||||
<div className="planner-actions">
|
||||
<button type="button" className="ghost" onClick={resetRange}>Reset</button>
|
||||
<button type="submit" disabled={plannerLoading}>{plannerLoading ? "Calculating..." : "Calculate"}</button>
|
||||
</div>
|
||||
</form>
|
||||
{plannerRows.length > 0 && (
|
||||
<div className="table">
|
||||
<div className="table-head">
|
||||
<span>Medication</span>
|
||||
<span>Usage</span>
|
||||
<span>Blisters needed</span>
|
||||
<span>Available</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
{plannerRows.map((row) => (
|
||||
<div key={row.medicationId} className="table-row">
|
||||
<span>{row.medicationName}</span>
|
||||
<span><strong>{row.plannerUsage}</strong> pills</span>
|
||||
<span>{row.stripsNeeded} × {row.stripSize}</span>
|
||||
<span>{row.stripsAvailable}</span>
|
||||
<span className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? "Enough" : "Low"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
} />
|
||||
|
||||
<Route path="/settings" element={
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Email Notifications</h2>
|
||||
<span className="pill neutral">Reminder settings</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 Email Reminders</label>
|
||||
<p className="setting-desc">Get notified when medication is running low</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>
|
||||
Notification Email
|
||||
<input
|
||||
type="email"
|
||||
value={settings.notificationEmail}
|
||||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Remind me (days before)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="30"
|
||||
value={settings.reminderDaysBefore}
|
||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>SMTP Configuration</h3>
|
||||
<p className="setting-hint">Diese Einstellungen werden in der <code>.env</code> Datei konfiguriert.</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 ? "Ja" : "Nein"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="time-col">
|
||||
<div className="time-chip times-chip">{item.times.join(" · ")}</div>
|
||||
</div>
|
||||
</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"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "medications" && (
|
||||
<section className="grid">
|
||||
<article className="card meds">
|
||||
<div className="card-head">
|
||||
<h2>Medication list</h2>
|
||||
<span className="pill neutral">{loading ? "Loading..." : `${meds.length} entries`}</span>
|
||||
</div>
|
||||
<div className="med-list">
|
||||
{meds.map((med) => (
|
||||
<div key={med.id} className="med-row">
|
||||
<div className="med-header">
|
||||
<div className="med-info">
|
||||
<div className="med-name">{med.name}</div>
|
||||
<div className="med-details">
|
||||
<span>Packs: <strong>{med.packCount ?? 1}</strong></span>
|
||||
<span>Blisters per pack: <strong>{med.stripsPerPack ?? med.strips ?? 1}</strong></span>
|
||||
<span>Pills per blister: <strong>{med.tabsPerStrip ?? med.stripSize}</strong></span>
|
||||
<span>Loose: <strong>{med.looseTablets ?? 0}</strong></span>
|
||||
</div>
|
||||
<div className="med-total">Total: {med.count} pills</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
<button className="ghost" onClick={() => startEdit(med)}>Edit</button>
|
||||
<button className="ghost danger" onClick={() => deleteMed(med.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="slice-list">
|
||||
{med.slices.map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="slice-row-simple">
|
||||
{s.usage} {s.usage === 1 ? "pill" : "pills"} · every {s.every} {s.every === 1 ? "day" : "days"} · from {formatDateTime(s.start)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="card form">
|
||||
<div className="card-head">
|
||||
<h2>{editingId ? "Edit entry" : "New entry"}</h2>
|
||||
<span className="pill">Packs + loose pills</span>
|
||||
</div>
|
||||
<form className="form-grid" onSubmit={saveMedication}>
|
||||
<label>
|
||||
Name
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="z.B. Lisinopril" required />
|
||||
</label>
|
||||
<label>
|
||||
Packs
|
||||
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Blisters per pack
|
||||
<input type="number" min="1" value={form.stripsPerPack} onChange={(e) => handleValueChange("stripsPerPack", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Pills per blister
|
||||
<input type="number" min="1" value={form.tabsPerStrip} onChange={(e) => handleValueChange("tabsPerStrip", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Loose pills
|
||||
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Total (pills)
|
||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||
</label>
|
||||
|
||||
<div className="full slices">
|
||||
<div className="card-head">
|
||||
<h3>Intake schedule</h3>
|
||||
<button type="button" className="ghost" onClick={addSlice}>+ Intake</button>
|
||||
</div>
|
||||
{form.slices.map((s, idx) => (
|
||||
<div key={idx} className="slice-row">
|
||||
<div className="slice-inputs">
|
||||
<label>
|
||||
Usage (pills)
|
||||
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setSliceValue(idx, "usage", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Every (days)
|
||||
<input type="number" min="1" value={s.every} onChange={(e) => setSliceValue(idx, "every", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Start (date/time)
|
||||
<input type="datetime-local" step="60" value={s.start} onChange={(e) => setSliceValue(idx, "start", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
{form.slices.length > 1 && (
|
||||
<button type="button" className="ghost" onClick={() => removeSlice(idx)}>Remove</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="full align-end gap">
|
||||
{editingId && (
|
||||
<button type="button" className="ghost" onClick={resetForm}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={saving}>{saving ? "Saving..." : "Save"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === "planner" && (
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Demand Calculator</h2>
|
||||
<span className="pill neutral">Plan your supply</span>
|
||||
</div>
|
||||
<form className="planner" onSubmit={runPlanner}>
|
||||
<label>
|
||||
From
|
||||
<input type="datetime-local" step="60" value={range.start} onChange={(e) => setRange({ ...range, start: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Until
|
||||
<input type="datetime-local" step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||
</label>
|
||||
<div className="planner-actions">
|
||||
<button type="button" className="ghost" onClick={resetRange}>Reset</button>
|
||||
<button type="submit" disabled={plannerLoading}>{plannerLoading ? "Calculating..." : "Calculate"}</button>
|
||||
</div>
|
||||
</form>
|
||||
{plannerRows.length > 0 && (
|
||||
<div className="table">
|
||||
<div className="table-head">
|
||||
<span>Medication</span>
|
||||
<span>Usage</span>
|
||||
<span>Blisters needed</span>
|
||||
<span>Available</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
{plannerRows.map((row) => (
|
||||
<div key={row.medicationId} className="table-row">
|
||||
<span>{row.medicationName}</span>
|
||||
<span><strong>{row.plannerUsage}</strong> pills</span>
|
||||
<span>{row.stripsNeeded} × {row.stripSize}</span>
|
||||
<span>{row.stripsAvailable}</span>
|
||||
<span className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? "Enough" : "Low"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
} />
|
||||
</Routes>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
+328
-42
@@ -1,12 +1,55 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap");
|
||||
|
||||
:root {
|
||||
--bg-primary: #0b1220;
|
||||
--bg-secondary: #111827;
|
||||
--bg-tertiary: #0d1424;
|
||||
--bg-input: #0a1018;
|
||||
--bg-gradient: radial-gradient(circle at 20% 20%, #1a2440, #0b1220 40%), #0b1220;
|
||||
--border-primary: #1f2a3d;
|
||||
--border-secondary: #2a3a4d;
|
||||
--text-primary: #e5e7eb;
|
||||
--text-secondary: #a3adc2;
|
||||
--text-muted: #cbd5f5;
|
||||
--accent: #2f86f6;
|
||||
--accent-light: #7ca7ff;
|
||||
--accent-bg: rgba(47, 134, 246, 0.1);
|
||||
--success: #6ee7b7;
|
||||
--success-bg: rgba(57, 217, 138, 0.12);
|
||||
--danger: #fca5a5;
|
||||
--danger-bg: rgba(255, 94, 94, 0.12);
|
||||
--shadow: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--bg-input: #ffffff;
|
||||
--bg-gradient: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 100%);
|
||||
--border-primary: #e2e8f0;
|
||||
--border-secondary: #cbd5e1;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #475569;
|
||||
--accent: #2563eb;
|
||||
--accent-light: #3b82f6;
|
||||
--accent-bg: rgba(37, 99, 235, 0.1);
|
||||
--success: #10b981;
|
||||
--success-bg: rgba(16, 185, 129, 0.1);
|
||||
--danger: #ef4444;
|
||||
--danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--shadow: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at 20% 20%, #1a2440, #0b1220 40%), #0b1220;
|
||||
color: #e5e7eb;
|
||||
background: var(--bg-gradient);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
transition: background 200ms ease, color 200ms ease;
|
||||
}
|
||||
|
||||
.page {
|
||||
@@ -17,19 +60,45 @@ body {
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, rgba(67, 106, 255, 0.08), rgba(115, 195, 255, 0.06));
|
||||
border: 1px solid rgba(73, 117, 255, 0.2);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 8px 32px var(--shadow);
|
||||
margin-bottom: 1.5rem;
|
||||
transition: background 200ms ease, border-color 200ms ease;
|
||||
}
|
||||
|
||||
[data-theme=\"light\"] .hero {
|
||||
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; }
|
||||
|
||||
.theme-toggle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid var(--border-primary);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 150ms ease, background 150ms ease;
|
||||
padding: 0;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
background: rgba(47, 134, 246, 0.2);
|
||||
}
|
||||
|
||||
.hero h1 { margin: 0.15rem 0 0; font-size: 1.6rem; font-weight: 600; }
|
||||
.sub { color: #b7c2e5; margin: 0; }
|
||||
.sub { color: var(--text-secondary); margin: 0; }
|
||||
.eyebrow { letter-spacing: 0.06em; text-transform: uppercase; color: #7ca7ff; font-size: 0.75rem; margin: 0; font-weight: 500; }
|
||||
|
||||
.tabs { display: flex; gap: 0.5rem; }
|
||||
@@ -45,45 +114,49 @@ body {
|
||||
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); margin-bottom: 1rem; }
|
||||
|
||||
.card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2a3d;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28);
|
||||
box-shadow: 0 14px 36px var(--shadow);
|
||||
transition: background 200ms ease, border-color 200ms ease;
|
||||
}
|
||||
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.card h2 { margin: 0; font-size: 1.2rem; }
|
||||
|
||||
.pill { border: 1px solid #2f86f6; color: #dceaff; background: rgba(47, 134, 246, 0.1); padding: 0.35rem 0.7rem; border-radius: 999px; font-size: 0.85rem; }
|
||||
.pill.success { border-color: #39d98a; background: rgba(57, 217, 138, 0.12); color: #c6f4dc; }
|
||||
.pill.neutral { border-color: #4b5565; background: rgba(255, 255, 255, 0.04); color: #cbd5f5; }
|
||||
.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.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); }
|
||||
|
||||
.badges { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.6rem; }
|
||||
|
||||
.meds .med-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.med-row { display: flex; flex-direction: column; gap: 0.75rem; border: 1px solid #1f2a3d; padding: 1rem; border-radius: 10px; background: #0d1424; position: relative; }
|
||||
.med-row { display: flex; flex-direction: column; gap: 0.75rem; border: 1px solid var(--border-primary); padding: 1rem; border-radius: 10px; background: var(--bg-tertiary); position: relative; transition: background 200ms ease, border-color 200ms ease; }
|
||||
.med-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
|
||||
.med-info { flex: 1; min-width: 0; }
|
||||
.med-name { font-weight: 600; font-size: 1.1rem; margin-bottom: 0.4rem; }
|
||||
.med-details { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.25rem 1.5rem; color: #a3adc2; font-size: 0.9rem; margin-bottom: 0.5rem; }
|
||||
.med-details strong { color: #dceaff; font-weight: 600; margin-left: 0.25rem; }
|
||||
.med-total { color: #dceaff; font-weight: 600; font-size: 0.95rem; margin-bottom: 0.5rem; }
|
||||
.muted { color: #a3adc2; font-size: 0.95rem; }
|
||||
.med-details { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.25rem 1.5rem; color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.5rem; }
|
||||
.med-details strong { color: var(--text-primary); font-weight: 600; margin-left: 0.25rem; }
|
||||
.med-total { color: var(--text-primary); font-weight: 600; font-size: 0.95rem; margin-bottom: 0.5rem; }
|
||||
.muted { color: var(--text-secondary); font-size: 0.95rem; }
|
||||
.small { font-size: 0.9rem; }
|
||||
|
||||
.slice-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; width: 100%; }
|
||||
.slice-row-simple { color: #cbd5f5; font-size: 0.9rem; padding: 0.5rem 0.75rem; background: #0f192c; border: 1px solid #1f2a3d; border-radius: 6px; width: 100%; }
|
||||
.slice-row-simple { color: var(--text-muted); font-size: 0.9rem; padding: 0.5rem 0.75rem; background: var(--bg-tertiary); border: 1px solid var(--border-primary); border-radius: 6px; width: 100%; transition: background 200ms ease; }
|
||||
|
||||
|
||||
|
||||
.tag { display: inline-flex; align-items: center; gap: 0.3rem; background: rgba(255, 255, 255, 0.06); border-radius: 6px; padding: 0.3rem 0.6rem; color: #dce3f5; font-size: 0.8rem; font-weight: 500; }
|
||||
.tag.subtle { background: rgba(255, 255, 255, 0.04); color: #a3adc2; font-size: 0.85rem; }
|
||||
.tag.success { background: rgba(57, 217, 138, 0.12); color: #6ee7b7; border: 1px solid rgba(57, 217, 138, 0.25); }
|
||||
.tag.danger { background: rgba(255, 94, 94, 0.12); color: #fca5a5; border: 1px solid rgba(255, 94, 94, 0.3); }
|
||||
.tag { display: inline-flex; align-items: center; gap: 0.3rem; background: rgba(255, 255, 255, 0.06); border-radius: 6px; padding: 0.3rem 0.6rem; color: var(--text-muted); font-size: 0.8rem; font-weight: 500; }
|
||||
[data-theme=\"light\"] .tag { background: rgba(0, 0, 0, 0.06); }
|
||||
.tag.subtle { background: rgba(255, 255, 255, 0.04); color: var(--text-secondary); font-size: 0.85rem; }
|
||||
[data-theme=\"light\"] .tag.subtle { background: rgba(0, 0, 0, 0.04); }
|
||||
.tag.success { background: var(--success-bg); color: var(--success); border: 1px solid rgba(57, 217, 138, 0.25); }
|
||||
.tag.danger { background: var(--danger-bg); color: var(--danger); border: 1px solid rgba(255, 94, 94, 0.3); }
|
||||
.tag-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.25rem; }
|
||||
.danger-text { color: #ff8f8f; font-weight: 700; }
|
||||
.success-text { color: #9be8c7; font-weight: 700; }
|
||||
.danger-text { color: var(--danger); font-weight: 700; }
|
||||
.success-text { color: var(--success); font-weight: 700; }
|
||||
|
||||
.med-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||
.med-actions button { padding: 0.5rem 0.9rem; }
|
||||
@@ -94,7 +167,8 @@ body {
|
||||
}
|
||||
.slice-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
|
||||
.slice-pill { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.slice-row { display: flex; flex-direction: column; gap: 0.75rem; background: rgba(15, 25, 44, 0.5); border: 1px solid #1f2a3d; padding: 1rem; border-radius: 8px; margin-bottom: 0.65rem; }
|
||||
.slice-row { display: flex; flex-direction: column; gap: 0.75rem; background: var(--bg-tertiary); border: 1px solid var(--border-primary); padding: 1rem; border-radius: 8px; margin-bottom: 0.65rem; transition: background 200ms ease; }
|
||||
[data-theme=\"light\"] .slice-row { background: var(--bg-tertiary); }
|
||||
.slice-row .slice-inputs { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 1rem; align-items: end; }
|
||||
.slice-row button { align-self: flex-end; width: auto; }
|
||||
.slice-row:last-child { margin-bottom: 0; }
|
||||
@@ -105,7 +179,7 @@ button {
|
||||
padding: 0.7rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #2f86f6, #3fa9f5);
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-light));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
@@ -114,58 +188,60 @@ button {
|
||||
}
|
||||
button:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(47, 134, 246, 0.35); }
|
||||
button:active { transform: translateY(0); }
|
||||
button.ghost { background: transparent; border: 1px solid #3a475f; color: #d0d8ec; box-shadow: none; }
|
||||
button.ghost { background: transparent; border: 1px solid var(--border-secondary); color: var(--text-muted); box-shadow: none; }
|
||||
button.ghost:hover { background: rgba(255, 255, 255, 0.06); transform: none; }
|
||||
button.ghost.danger { border-color: #5a3a3a; color: #ff9a9a; }
|
||||
button.ghost.danger:hover { background: rgba(255, 94, 94, 0.1); }
|
||||
[data-theme=\"light\"] button.ghost:hover { background: rgba(0, 0, 0, 0.04); }
|
||||
button.ghost.danger { border-color: rgba(239, 68, 68, 0.4); color: var(--danger); }
|
||||
button.ghost.danger:hover { background: var(--danger-bg); }
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a3a4d;
|
||||
background: #0a1018;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid var(--border-secondary);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease, background 200ms ease;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #2f86f6;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(47, 134, 246, 0.15);
|
||||
}
|
||||
|
||||
.static-value {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(47, 134, 246, 0.08);
|
||||
border: 1px solid #2f86f6;
|
||||
color: #dceaff;
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem 1.25rem; }
|
||||
.form-grid label { display: flex; flex-direction: column; gap: 0.4rem; color: #a3b3c8; font-size: 0.85rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.form-grid label { display: flex; flex-direction: column; gap: 0.4rem; color: var(--text-secondary); font-size: 0.85rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.align-end { display: flex; justify-content: flex-end; gap: 0.75rem; }
|
||||
|
||||
.timeline { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.day-block { border: 1px solid #1f2a3d; border-radius: 16px; padding: 1rem 1.25rem; background: linear-gradient(135deg, #0d1322 0%, #111827 100%); box-shadow: 0 8px 32px rgba(0,0,0,0.25); }
|
||||
.day-block { border: 1px solid var(--border-primary); border-radius: 16px; padding: 1rem 1.25rem; background: var(--bg-secondary); box-shadow: 0 8px 32px var(--shadow); transition: background 200ms ease, border-color 200ms ease; }
|
||||
.day-divider {
|
||||
margin: 0 0 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(47, 134, 246, 0.2);
|
||||
color: #7ca7ff;
|
||||
color: var(--accent-light);
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.time-row { display: grid; grid-template-columns: minmax(200px, 280px) 1fr; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
[data-theme=\"light\"] .time-row { border-bottom-color: rgba(0,0,0,0.06); }
|
||||
.time-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.time-main { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.time-main .med-name { font-size: 1rem; font-weight: 600; color: #e5e7eb; margin: 0; }
|
||||
.time-main .med-name { font-size: 1rem; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.time-col { display: flex; align-items: center; justify-content: flex-start; }
|
||||
.time-chip {
|
||||
display: inline-flex;
|
||||
@@ -174,8 +250,8 @@ input:focus, select:focus {
|
||||
border: 1px solid rgba(47, 134, 246, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(47, 134, 246, 0.08);
|
||||
color: #93c5fd;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-light);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -291,3 +367,213 @@ input:focus, select:focus {
|
||||
@media (max-width: 600px) {
|
||||
.planner { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Settings styles */
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-row.inline {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-group label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.setting-section {
|
||||
padding: 1.25rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.setting-section h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.setting-section .setting-desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-section .setting-hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--accent-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.setting-section .setting-hint code {
|
||||
background: var(--bg-input);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.smtp-readonly {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.smtp-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.smtp-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.smtp-value {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.smtp-readonly {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch.small {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--border-secondary);
|
||||
border-radius: 28px;
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.toggle-switch.small .toggle-slider::before {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider::before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.toggle-switch.small input:checked + .toggle-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.setting-group { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Generated
+1391
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user