feat(share): implement share functionality for medication schedules with token-based access

This commit is contained in:
Daniel Volz
2025-12-26 21:06:03 +01:00
parent a7f9f90db4
commit b0f26b1e66
8 changed files with 887 additions and 16 deletions
+10
View File
@@ -83,6 +83,16 @@ async function main() {
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS share_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
// Execute each statement separately
+12
View File
@@ -87,3 +87,15 @@ export const refreshTokens = sqliteTable("refresh_tokens", {
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Share Tokens - For public schedule sharing by takenBy person
// =============================================================================
export const shareTokens = sqliteTable("share_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
token: text("token", { length: 64 }).notNull().unique(),
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
+2
View File
@@ -16,6 +16,7 @@ import { authRoutes } from "./routes/auth.js";
import { medicationRoutes } from "./routes/medications.js";
import { settingsRoutes } from "./routes/settings.js";
import { plannerRoutes } from "./routes/planner.js";
import { shareRoutes } from "./routes/share.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
@@ -99,6 +100,7 @@ await app.register(authRoutes);
await app.register(medicationRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
const start = async () => {
try {
+152
View File
@@ -0,0 +1,152 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "crypto";
import { db } from "../db/client.js";
import { medications, shareTokens } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { requireAuth, optionalAuth } from "../plugins/auth.js";
import type { AuthUser } from "../types/fastify.js";
// =============================================================================
// Validation Schemas
// =============================================================================
const createShareSchema = z.object({
takenBy: z.string().min(1, "takenBy is required"),
scheduleDays: z.number().int().min(1).max(365).default(30),
});
// =============================================================================
// Share Routes
// =============================================================================
export async function shareRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /share/:token - PUBLIC: Get shared schedule by token
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => {
const { token } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
}
// Get medications for this user filtered by takenBy
const meds = await db.select().from(medications).where(
and(
eq(medications.userId, share.userId),
eq(medications.takenBy, share.takenBy)
)
);
// Parse slices and build schedule data
const medicationsWithSlices = meds.map((med) => {
let slices: { usage: number; every: number; start: string }[] = [];
try {
const usageArr = JSON.parse(med.usageJson || "[]");
const everyArr = JSON.parse(med.everyJson || "[]");
const startArr = JSON.parse(med.startJson || "[]");
slices = usageArr.map((usage: number, i: number) => ({
usage,
every: everyArr[i] ?? 1,
start: startArr[i] ?? new Date().toISOString(),
}));
} catch {
slices = [];
}
return {
id: med.id,
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
imageUrl: med.imageUrl,
slices,
};
});
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
medications: medicationsWithSlices,
};
});
// ---------------------------------------------------------------------------
// POST /share - PROTECTED: Create a new share link
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof createShareSchema> }>(
"/share",
{ preHandler: requireAuth },
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const parsed = createShareSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR",
});
}
const { takenBy, scheduleDays } = parsed.data;
// Check if user has medications for this takenBy
const [existingMed] = await db.select().from(medications).where(
and(
eq(medications.userId, authUser.id),
eq(medications.takenBy, takenBy)
)
);
if (!existingMed) {
return reply.status(400).send({
error: "No medications found for this person",
code: "NO_MEDICATIONS",
});
}
// Generate unique token (8 bytes = 16 hex chars)
const token = randomBytes(8).toString("hex");
// Create share token
await db.insert(shareTokens).values({
userId: authUser.id,
token,
takenBy,
scheduleDays,
});
return {
token,
shareUrl: `/share/${token}`,
};
}
);
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
app.get(
"/share/people",
{ preHandler: requireAuth },
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
// Get all unique takenBy values for this user
const meds = await db.select({ takenBy: medications.takenBy })
.from(medications)
.where(eq(medications.userId, authUser.id));
const uniquePeople = [...new Set(meds.map((m) => m.takenBy).filter(Boolean))] as string[];
return { people: uniquePeople };
}
);
}
+460 -16
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Routes, Route, useNavigate, useLocation, Navigate } from "react-router-dom";
import { Routes, Route, useNavigate, useLocation, Navigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AuthProvider, useAuth, AuthPage, UserProfile } from "./components/Auth";
@@ -85,7 +85,12 @@ type Coverage = {
export default function App() {
return (
<AuthProvider>
<AppRouter />
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
</Routes>
</AuthProvider>
);
}
@@ -221,6 +226,14 @@ function AppContent() {
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [scheduleDays, setScheduleDays] = useState<number>(30);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
// Share dialog state
const [showShareDialog, setShowShareDialog] = useState(false);
const [sharePeople, setSharePeople] = useState<string[]>([]);
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
const [shareGenerating, setShareGenerating] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null);
const [shareCopied, setShareCopied] = useState(false);
// Load user-specific scheduleDays and takenDoses when user changes
useEffect(() => {
@@ -627,6 +640,66 @@ function AppContent() {
}
}
// Share dialog functions
async function openShareDialog() {
setShowShareDialog(true);
setShareLink(null);
setShareCopied(false);
setShareSelectedPerson("");
setShareSelectedDays(30);
// Get unique takenBy people from medications
const uniquePeople = [...new Set(meds.map(m => m.takenBy).filter(Boolean))] as string[];
setSharePeople(uniquePeople);
if (uniquePeople.length > 0) {
setShareSelectedPerson(uniquePeople[0]);
}
}
async function generateShareLink() {
if (!shareSelectedPerson) return;
setShareGenerating(true);
setShareCopied(false);
try {
const res = await fetch("/api/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
takenBy: shareSelectedPerson,
scheduleDays: shareSelectedDays,
}),
});
if (res.ok) {
const data = await res.json();
const fullUrl = `${window.location.origin}/share/${data.token}`;
setShareLink(fullUrl);
} else {
const err = await res.json();
alert(err.error || "Failed to generate share link");
}
} catch {
alert("Failed to generate share link");
} finally {
setShareGenerating(false);
}
}
function copyShareLink() {
if (shareLink) {
navigator.clipboard.writeText(shareLink);
setShareCopied(true);
setTimeout(() => setShareCopied(false), 2000);
}
}
function closeShareDialog() {
setShowShareDialog(false);
setShareLink(null);
setShareCopied(false);
}
const [theme, setTheme] = useState<"light" | "dark">(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
@@ -832,20 +905,27 @@ function AppContent() {
<section className="grid">
<article className="card">
<div className="card-head">
<h2 className="clickable" onClick={() => navigate("/schedule")}>{t('dashboard.schedules.title')}</h2>
<select
className="schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t('dashboard.schedules.1month')}</option>
<option value={90}>{t('dashboard.schedules.3months')}</option>
<option value={180}>{t('dashboard.schedules.6months')}</option>
</select>
<h2>{t('dashboard.schedules.title')}</h2>
<div className="card-head-actions">
{meds.some(m => m.takenBy) && (
<button className="ghost share-btn" onClick={openShareDialog} title={t('share.button')}>
🔗 {t('share.button')}
</button>
)}
<select
className="schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t('dashboard.schedules.1month')}</option>
<option value={90}>{t('dashboard.schedules.3months')}</option>
<option value={180}>{t('dashboard.schedules.6months')}</option>
</select>
</div>
</div>
<div className="timeline">
{groupedSchedule.map((day) => (
@@ -1713,6 +1793,82 @@ function AppContent() {
</div>
</div>
)}
{/* Share Dialog Modal */}
{showShareDialog && (
<div className="modal-overlay" onClick={closeShareDialog}>
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={closeShareDialog}>×</button>
<div className="share-dialog-header">
<h2>🔗 {t('share.title')}</h2>
<p className="share-dialog-description">{t('share.description')}</p>
</div>
{sharePeople.length === 0 ? (
<div className="share-dialog-empty">
<p>{t('share.noPeople')}</p>
</div>
) : shareLink ? (
<div className="share-dialog-result">
<p className="share-success">{t('share.linkGenerated')}</p>
<div className="share-link-box">
<input
type="text"
value={shareLink}
readOnly
className="share-link-input"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button className="btn-copy" onClick={copyShareLink}>
{shareCopied ? "✓" : "📋"}
</button>
</div>
{shareCopied && <span className="share-copied-hint">{t('share.copied')}</span>}
<div className="share-dialog-footer">
<button className="ghost" onClick={() => { setShareLink(null); setShareCopied(false); }}>
{t('share.generateAnother')}
</button>
<button onClick={closeShareDialog}>{t('common.close')}</button>
</div>
</div>
) : (
<div className="share-dialog-form">
<div className="form-group">
<label>{t('share.selectPerson')}</label>
<select
value={shareSelectedPerson}
onChange={(e) => setShareSelectedPerson(e.target.value)}
>
{sharePeople.map((person) => (
<option key={person} value={person}>{person}</option>
))}
</select>
</div>
<div className="form-group">
<label>{t('share.selectPeriod')}</label>
<select
value={shareSelectedDays}
onChange={(e) => setShareSelectedDays(Number(e.target.value))}
>
<option value={30}>{t('dashboard.schedules.1month')}</option>
<option value={90}>{t('dashboard.schedules.3months')}</option>
<option value={180}>{t('dashboard.schedules.6months')}</option>
</select>
</div>
<div className="share-dialog-footer">
<button className="ghost" onClick={closeShareDialog}>{t('common.cancel')}</button>
<button onClick={generateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
{shareGenerating ? t('share.generating') : t('share.generateLink')}
</button>
</div>
</div>
)}
</div>
</div>
)}
</main>
);
}
@@ -2108,3 +2264,291 @@ function MedicationAvatar({ name, imageUrl, size = "sm" }: { name: string; image
}
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
}
// =============================================================================
// Shared Schedule Component - Public view for shared schedules
// =============================================================================
type SharedMedication = {
id: number;
name: string;
genericName?: string | null;
pillWeightMg?: number | null;
imageUrl?: string | null;
slices: Slice[];
};
type SharedScheduleData = {
takenBy: string;
scheduleDays: number;
medications: SharedMedication[];
};
function SharedSchedule() {
const { token } = useParams<{ token: string }>();
const { t, i18n } = useTranslation();
const [data, setData] = useState<SharedScheduleData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
// Close lightbox on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && lightboxImage) {
setLightboxImage(null);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lightboxImage]);
// Load taken doses from localStorage
useEffect(() => {
if (token) {
try {
const storedDoses = localStorage.getItem(`share_${token}_takenDoses`);
if (storedDoses) {
const parsed = JSON.parse(storedDoses);
// Clean up old doses (older than 7 days)
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const filtered = parsed.filter((item: { id: string; timestamp: number }) => item.timestamp > weekAgo);
setTakenDoses(new Set(filtered.map((item: { id: string }) => item.id)));
}
} catch {
setTakenDoses(new Set());
}
}
}, [token]);
function markDoseTaken(doseId: string) {
setTakenDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
// Persist with timestamp for cleanup
const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() }));
if (token) {
localStorage.setItem(`share_${token}_takenDoses`, JSON.stringify(items));
}
return next;
});
}
function undoDoseTaken(doseId: string) {
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() }));
if (token) {
localStorage.setItem(`share_${token}_takenDoses`, JSON.stringify(items));
}
return next;
});
}
useEffect(() => {
async function fetchData() {
if (!token) {
setError("Invalid link");
setLoading(false);
return;
}
try {
const res = await fetch(`/api/share/${token}`);
if (res.ok) {
const json = await res.json();
setData(json);
} else {
setError("Share link not found or expired");
}
} catch {
setError("Failed to load schedule");
} finally {
setLoading(false);
}
}
fetchData();
}, [token]);
// Build schedule from medications
const schedule = useMemo(() => {
if (!data) return [];
const now = Date.now();
// Start from 7 days ago to show past doses
const startTime = now - 7 * 24 * 60 * 60 * 1000;
const endTime = now + data.scheduleDays * 24 * 60 * 60 * 1000;
const doses: { id: string; when: number; medName: string; usage: number; timeStr: string }[] = [];
for (const med of data.medications) {
for (const slice of med.slices) {
const startDate = new Date(slice.start);
const intervalMs = slice.every * 24 * 60 * 60 * 1000;
let t = startDate.getTime();
// Move to first occurrence >= startTime
if (t < startTime) {
const elapsed = startTime - t;
const periods = Math.floor(elapsed / intervalMs);
t += periods * intervalMs;
if (t < startTime) t += intervalMs;
}
while (t <= endTime) {
const d = new Date(t);
// Generate unique dose ID
const doseId = `share-${med.id}-${slice.usage}-${slice.every}-${t}`;
doses.push({
id: doseId,
when: t,
medName: med.name,
usage: slice.usage,
timeStr: d.toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" }),
});
t += intervalMs;
}
}
}
doses.sort((a, b) => a.when - b.when);
// Group by date
const grouped: { dateStr: string; date: Date; meds: { medName: string; total: number; lastWhen: number; doses: typeof doses }[] }[] = [];
const byDate = new Map<string, typeof doses>();
for (const dose of doses) {
const dateKey = new Date(dose.when).toLocaleDateString(i18n.language, {
weekday: "long",
day: "2-digit",
month: "short",
});
if (!byDate.has(dateKey)) byDate.set(dateKey, []);
byDate.get(dateKey)!.push(dose);
}
for (const [dateStr, dayDoses] of byDate) {
const byMed = new Map<string, typeof doses>();
for (const dose of dayDoses) {
if (!byMed.has(dose.medName)) byMed.set(dose.medName, []);
byMed.get(dose.medName)!.push(dose);
}
const meds = Array.from(byMed.entries()).map(([medName, medDoses]) => ({
medName,
total: medDoses.reduce((sum, d) => sum + d.usage, 0),
lastWhen: Math.max(...medDoses.map(d => d.when)),
doses: medDoses,
}));
grouped.push({ dateStr, date: new Date(dayDoses[0].when), meds });
}
return grouped;
}, [data, i18n.language]);
if (loading) {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-loading">
<h1>💊 MedAssist</h1>
<p>{t('common.loading')}</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-error">
<h1>💊 MedAssist</h1>
<p className="error-message">{error || "Unknown error"}</p>
</div>
</div>
);
}
return (
<div className="shared-schedule-page">
<div className="shared-schedule-container">
<header className="shared-schedule-header">
<h1>💊 {t('share.scheduleFor')} {data.takenBy}</h1>
<p className="shared-schedule-period">
{t('share.period')}: {data.scheduleDays === 30 ? t('dashboard.schedules.1month') : data.scheduleDays === 90 ? t('dashboard.schedules.3months') : t('dashboard.schedules.6months')}
</p>
</header>
<div className="timeline">
{schedule.length === 0 ? (
<p className="shared-schedule-empty">{t('share.noSchedule')}</p>
) : (
schedule.map((day) => (
<div key={day.dateStr} className="day-block">
<div className="day-divider">{day.dateStr}</div>
{day.meds.map((item) => {
const med = data.medications.find(m => m.name === item.medName);
const allTaken = item.doses.every((d) => takenDoses.has(d.id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? 'clickable' : ''}
onClick={() => med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const isTaken = takenDoses.has(dose.id);
const isOverdue = dose.when < Date.now() && !isTaken;
return (
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
))
)}
</div>
<footer className="shared-schedule-footer">
<p>{t('share.generatedBy')} MedAssist</p>
</footer>
</div>
{/* Image Lightbox */}
{lightboxImage && (
<div className="lightbox-overlay" onClick={() => setLightboxImage(null)}>
<button className="lightbox-close" onClick={() => setLightboxImage(null)}>×</button>
<img
src={`/api/images/${lightboxImage.url}`}
alt={lightboxImage.name}
className="lightbox-image"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
);
}
+18
View File
@@ -275,5 +275,23 @@
"fullBlisters": "volle Blister",
"inBlister": "in 1 Blister",
"total": "gesamt"
},
"share": {
"button": "Teilen",
"title": "Zeitplan teilen",
"description": "Generiere einen geheimen Link, um den Medikamentenplan für eine bestimmte Person zu teilen. Jeder mit diesem Link kann den Zeitplan sehen.",
"selectPerson": "Person auswählen",
"selectPeriod": "Zeitraum auswählen",
"generateLink": "Link generieren",
"generating": "Wird generiert...",
"generateAnother": "Weiteren Link generieren",
"linkGenerated": "Teilen-Link erstellt!",
"copyLink": "Link kopieren",
"copied": "In Zwischenablage kopiert!",
"noPeople": "Keine Medikamente mit 'Eingenommen von' zugewiesen. Füge zuerst eine Person zu einem Medikament hinzu.",
"scheduleFor": "Zeitplan für",
"period": "Zeitraum",
"noSchedule": "Keine geplanten Einnahmen gefunden.",
"generatedBy": "Erstellt von"
}
}
+18
View File
@@ -277,5 +277,23 @@
"fullBlisters": "full blisters",
"inBlister": "in 1 blister",
"total": "total"
},
"share": {
"button": "Share",
"title": "Share Schedule",
"description": "Generate a secret link to share the medication schedule for a specific person. Anyone with this link can view the schedule.",
"selectPerson": "Select person",
"selectPeriod": "Select time period",
"generateLink": "Generate Link",
"generating": "Generating...",
"generateAnother": "Generate another link",
"linkGenerated": "Share link generated!",
"copyLink": "Copy Link",
"copied": "Copied to clipboard!",
"noPeople": "No medications with 'Taken by' assigned. Add a person to a medication first.",
"scheduleFor": "Schedule for",
"period": "Period",
"noSchedule": "No scheduled doses found.",
"generatedBy": "Generated by"
}
}
+215
View File
@@ -402,6 +402,8 @@ textarea {
.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: var(--text-primary); margin: 0; }
.time-main .med-name span.clickable { cursor: pointer; }
.time-main .med-name span.clickable:hover .med-avatar { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.time-col { display: flex; align-items: center; justify-content: flex-start; }
.time-chip {
display: inline-flex;
@@ -2401,3 +2403,216 @@ h3 .reminder-icon.info-tooltip {
max-width: 480px;
padding: 1.5rem;
}
/* =============================================================================
Share Dialog
============================================================================= */
.share-dialog-modal {
max-width: 480px;
padding: 1.5rem;
}
.share-dialog-header {
text-align: center;
margin-bottom: 1.5rem;
}
.share-dialog-header h2 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.share-dialog-description {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
.share-dialog-empty {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.share-dialog-form .form-group {
margin-bottom: 1rem;
}
.share-dialog-form label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.share-dialog-form select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 1rem;
}
.share-dialog-footer {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
justify-content: flex-end;
}
.share-dialog-result {
text-align: center;
}
.share-success {
color: var(--success);
font-weight: 500;
margin-bottom: 1rem;
}
.share-link-box {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.share-link-input {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 0.875rem;
font-family: monospace;
}
.btn-copy {
padding: 0.75rem 1rem;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-copy:hover {
background: var(--accent-bg);
border-color: var(--accent);
}
.share-copied-hint {
color: var(--success);
font-size: 0.875rem;
}
.share-btn {
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
}
.card-head-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
/* =============================================================================
Shared Schedule Page (Public)
============================================================================= */
.shared-schedule-page {
min-height: 100vh;
background: var(--bg-gradient);
padding: 2rem;
}
.shared-schedule-container {
max-width: 800px;
margin: 0 auto;
}
.shared-schedule-loading,
.shared-schedule-error {
text-align: center;
padding: 4rem 2rem;
}
.shared-schedule-loading h1,
.shared-schedule-error h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
.shared-schedule-error .error-message {
color: var(--danger);
font-size: 1.125rem;
}
.shared-schedule-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-primary);
}
.shared-schedule-header h1 {
font-size: 1.75rem;
margin-bottom: 0.5rem;
}
.shared-schedule-period {
color: var(--text-secondary);
font-size: 1rem;
}
.shared-timeline {
background: var(--bg-secondary);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--border-primary);
}
.shared-schedule-empty {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.shared-dose {
background: transparent;
border: none;
padding: 0.5rem 0;
}
.med-generic-inline {
font-size: 0.875rem;
color: var(--text-secondary);
margin-left: 0.5rem;
}
.shared-schedule-footer {
text-align: center;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-primary);
color: var(--text-secondary);
font-size: 0.875rem;
}
@media (max-width: 600px) {
.shared-schedule-page {
padding: 1rem;
}
.shared-schedule-header h1 {
font-size: 1.25rem;
}
.shared-timeline {
padding: 1rem;
}
}