feat: add user avatar functionality and update related routes
- Implemented avatar upload and deletion in the Auth context. - Updated UserProfile component to handle avatar display and actions. - Modified backend routes to return anonymous user ID when auth is disabled. - Added avatar_url column to users table in the database. - Enhanced UI for user menu and profile modal to support avatar display. - Updated translations for new avatar-related strings. - Improved stock status calculation for medications in the planner.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- Add avatar URL column to users table
|
||||
ALTER TABLE users ADD COLUMN avatar_url TEXT;
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "idx": 8, "version": 1, "when": 1735400000, "tag": "0008_add_pill_weight", "breakpoint": false },
|
||||
{ "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false },
|
||||
{ "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false },
|
||||
{ "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false }
|
||||
{ "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false },
|
||||
{ "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username", { length: 100 }).notNull().unique(),
|
||||
passwordHash: text("password_hash", { length: 255 }),
|
||||
avatarUrl: text("avatar_url", { length: 255 }),
|
||||
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
lastLoginAt: integer("last_login_at", { mode: "timestamp" }),
|
||||
|
||||
@@ -2,7 +2,45 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { env } from "./env.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { sql, count } from "drizzle-orm";
|
||||
import { sql, count, eq } from "drizzle-orm";
|
||||
|
||||
// =============================================================================
|
||||
// Anonymous User - Used when AUTH_ENABLED=false
|
||||
// Uses a fixed high ID (999999999) to never collide with regular users
|
||||
// =============================================================================
|
||||
const ANONYMOUS_USER_ID = 999999999;
|
||||
const ANONYMOUS_USERNAME = "__anonymous__";
|
||||
let anonymousUserVerified = false;
|
||||
|
||||
/**
|
||||
* Get or create the anonymous user for no-auth mode.
|
||||
* Uses a fixed ID (999999999) that will never collide with auto-increment IDs.
|
||||
*/
|
||||
export async function getAnonymousUserId(): Promise<number> {
|
||||
// Return cached if already verified
|
||||
if (anonymousUserVerified) {
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
// Check if anonymous user exists
|
||||
const [existing] = await db.select().from(users).where(eq(users.id, ANONYMOUS_USER_ID));
|
||||
|
||||
if (existing) {
|
||||
anonymousUserVerified = true;
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
// Create anonymous user with fixed ID (SQLite allows explicit ID)
|
||||
await db.run(sql`
|
||||
INSERT INTO users (id, username, password_hash, auth_provider, is_active, created_at, updated_at)
|
||||
VALUES (${ANONYMOUS_USER_ID}, ${ANONYMOUS_USERNAME}, NULL, 'anonymous', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
anonymousUserVerified = true;
|
||||
console.log(`Created anonymous user with fixed ID ${ANONYMOUS_USER_ID} for no-auth mode`);
|
||||
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth State - Computed at runtime
|
||||
@@ -16,7 +54,8 @@ export interface AuthState {
|
||||
}
|
||||
|
||||
export async function getAuthState(): Promise<AuthState> {
|
||||
const [result] = await db.select({ count: count() }).from(users);
|
||||
// Count only real users (not the anonymous user with fixed ID)
|
||||
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
|
||||
const hasUsers = result.count > 0;
|
||||
|
||||
return {
|
||||
|
||||
@@ -329,6 +329,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
authProvider: user.authProvider,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
@@ -385,4 +386,82 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
return { ok: true, message: "Profile updated" };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/avatar - Upload user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post("/auth/avatar", { 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 data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = data.filename.split(".").pop() || "jpg";
|
||||
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
|
||||
|
||||
// Save file
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
const imagesDir = path.join(process.cwd(), "data", "images");
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
await fs.writeFile(path.join(imagesDir, filename), buffer);
|
||||
|
||||
// Delete old avatar if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
try {
|
||||
await fs.unlink(path.join(imagesDir, user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: filename, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
|
||||
return { ok: true, avatarUrl: filename };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /auth/avatar - Delete user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete("/auth/avatar", { 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 [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (!user?.avatarUrl) {
|
||||
return reply.status(404).send({ error: "No avatar to delete" });
|
||||
}
|
||||
|
||||
// Delete file
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
try {
|
||||
await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||
import { eq, and, gte } from "drizzle-orm";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
@@ -19,11 +19,11 @@ const shareDoseSchema = z.object({
|
||||
});
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns a default user ID when auth is disabled
|
||||
function getUserId(request: any, reply: any): number {
|
||||
// If auth is disabled, use a default user ID (1)
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return 1;
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -45,7 +45,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
"/doses/taken",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Get doses from last 30 days (to avoid loading too much data)
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
@@ -76,7 +76,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
"/doses/taken",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = markDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
@@ -119,7 +119,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
"/doses/taken/:doseId",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const { doseId } = request.params;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { createWriteStream, existsSync, unlinkSync } from "fs";
|
||||
import { resolve, extname } from "path";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
@@ -58,11 +58,11 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns a default user ID when auth is disabled
|
||||
function getUserId(request: any, reply: any): number {
|
||||
// If auth is disabled, use a default user ID (1)
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return 1;
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -75,7 +75,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
app.get("/medications", async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -103,7 +103,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const parsed = medicationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
|
||||
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
|
||||
const everyJson = JSON.stringify(blisters.map((s) => s.every));
|
||||
@@ -163,7 +163,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
|
||||
// Verify ownership
|
||||
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
@@ -229,7 +229,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
|
||||
// Delete associated image if exists (with ownership check)
|
||||
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
@@ -250,7 +250,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
@@ -284,7 +284,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
@@ -308,7 +308,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
return reply.badRequest("Invalid date range");
|
||||
}
|
||||
|
||||
const userId = getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import type { Language } from "../i18n/translations.js";
|
||||
@@ -146,11 +146,11 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns a default user ID when auth is disabled
|
||||
function getUserId(request: any, reply: any): number {
|
||||
// If auth is disabled, use a default user ID (1)
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return 1;
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -163,7 +163,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
// Get settings for current user
|
||||
app.get("/settings", async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const settings = await getOrCreateUserSettings(userId);
|
||||
|
||||
@@ -201,7 +201,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
// Update settings for current user
|
||||
app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const body = request.body;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { randomBytes } from "crypto";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { requireAuth, optionalAuth } from "../plugins/auth.js";
|
||||
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
@@ -17,11 +17,11 @@ const createShareSchema = z.object({
|
||||
});
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns a default user ID when auth is disabled
|
||||
function getUserId(request: any, reply: any): number {
|
||||
// If auth is disabled, use a default user ID (1)
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return 1;
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -104,7 +104,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
"/share",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = createShareSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
@@ -156,7 +156,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
"/share/people",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request, reply);
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Get all unique takenBy values for this user
|
||||
const meds = await db.select({ takenBy: medications.takenBy })
|
||||
|
||||
+69
-8
@@ -184,7 +184,7 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
||||
|
||||
function AppContent() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user, authState } = useAuth();
|
||||
const { user, authState, logout } = useAuth();
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [meds, setMeds] = useState<Medication[]>([]);
|
||||
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
|
||||
@@ -408,6 +408,33 @@ function AppContent() {
|
||||
const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language, settings.reminderDaysBefore), [meds, schedule.events, i18n.language, settings.reminderDaysBefore]);
|
||||
const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]);
|
||||
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
|
||||
|
||||
// Get worst stock status for a day's medications (for coloring day blocks)
|
||||
const getDayStockStatus = (dayMeds: { medName: string; lastWhen: number }[]) => {
|
||||
const statuses = dayMeds.map((item) => {
|
||||
const cov = coverageByMed[item.medName];
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
|
||||
// Will be out of stock by this day?
|
||||
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (!cov) return "success";
|
||||
const { daysLeft, medsLeft } = cov;
|
||||
|
||||
// Currently out of stock
|
||||
if (medsLeft <= 0 || daysLeft === 0) return "danger";
|
||||
// No schedule (can't calculate)
|
||||
if (daysLeft === null) return "success";
|
||||
// Low stock: < lowStockDays (warning)
|
||||
if (daysLeft < settings.lowStockDays) return "warning";
|
||||
// Normal/High stock
|
||||
return "success";
|
||||
});
|
||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
||||
};
|
||||
|
||||
const groupedSchedule = useMemo(() => {
|
||||
type DoseInfo = { id: string; timeStr: string; when: number; usage: number };
|
||||
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }> }>();
|
||||
@@ -906,15 +933,47 @@ function AppContent() {
|
||||
<button className={currentPath === "/medications" ? "pill primary" : "pill"} onClick={() => navigate("/medications")}>{t('nav.medications')}</button>
|
||||
<button className={currentPath === "/planner" ? "pill primary" : "pill"} onClick={() => navigate("/planner")}>{t('nav.planner')}</button>
|
||||
</div>
|
||||
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}>⚙️</button>
|
||||
{/* Settings button only shown when auth is disabled (no user dropdown available) */}
|
||||
{!authState?.authEnabled && (
|
||||
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}>⚙️</button>
|
||||
)}
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
{authState?.authEnabled && user && (
|
||||
<button className="user-menu-btn" onClick={() => setShowProfile(true)} title={t('auth.profile', 'Profile')}>
|
||||
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
|
||||
<span>{user.username}</span>
|
||||
</button>
|
||||
<div className="user-menu">
|
||||
<button className="user-menu-btn">
|
||||
{user.avatarUrl ? (
|
||||
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
|
||||
) : (
|
||||
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="user-dropdown">
|
||||
<div className="dropdown-header">
|
||||
{user.avatarUrl ? (
|
||||
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="dropdown-avatar-img" />
|
||||
) : (
|
||||
<div className="dropdown-avatar">{user.username.charAt(0).toUpperCase()}</div>
|
||||
)}
|
||||
<span className="dropdown-username">{user.username}</span>
|
||||
</div>
|
||||
<div className="dropdown-menu">
|
||||
<button className="dropdown-item" onClick={() => setShowProfile(true)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{t('auth.profile', 'Profile')}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => navigate('/settings')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
{t('nav.settings', 'Settings')}
|
||||
</button>
|
||||
<button className="dropdown-item danger" onClick={() => logout()}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
{t('auth.signOut', 'Sign Out')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
@@ -1123,9 +1182,10 @@ function AppContent() {
|
||||
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
const worstStatus = getDayStockStatus(day.meds);
|
||||
|
||||
return (
|
||||
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
|
||||
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||
@@ -1891,9 +1951,10 @@ function AppContent() {
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
const worstStatus = getDayStockStatus(day.meds);
|
||||
|
||||
return (
|
||||
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
|
||||
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from "react";
|
||||
import { useState, useEffect, createContext, useContext, ReactNode, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// =============================================================================
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
@@ -27,6 +28,8 @@ interface AuthContextType {
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
|
||||
uploadAvatar: (file: File) => Promise<void>;
|
||||
deleteAvatar: () => Promise<void>;
|
||||
authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
@@ -195,6 +198,40 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
await refreshUser();
|
||||
}
|
||||
|
||||
// Upload avatar
|
||||
async function uploadAvatar(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/api/auth/avatar", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Upload failed" }));
|
||||
throw new Error(err.error || "Upload failed");
|
||||
}
|
||||
|
||||
await refreshUser();
|
||||
}
|
||||
|
||||
// Delete avatar
|
||||
async function deleteAvatar() {
|
||||
const res = await fetch("/api/auth/avatar", {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Delete failed" }));
|
||||
throw new Error(err.error || "Delete failed");
|
||||
}
|
||||
|
||||
await refreshUser();
|
||||
}
|
||||
|
||||
// Fetch wrapper that automatically refreshes token on 401
|
||||
const authFetch = useCallback(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const options: RequestInit = {
|
||||
@@ -220,7 +257,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile, authFetch }}>
|
||||
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile, uploadAvatar, deleteAvatar, authFetch }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
@@ -424,13 +461,45 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
||||
// =============================================================================
|
||||
export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { user, logout, updateProfile } = useAuth();
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setAvatarLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await uploadAvatar(file);
|
||||
setSuccess(t("auth.avatarUpdated", "Avatar updated"));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAvatarDelete() {
|
||||
setAvatarLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteAvatar();
|
||||
setSuccess(t("auth.avatarRemoved", "Avatar removed"));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed");
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -442,6 +511,11 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
setError(t("auth.fillAllFields", "Please fill in all password fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -460,69 +534,105 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const hasChanges = currentPassword || newPassword || confirmPassword;
|
||||
|
||||
return (
|
||||
<div className="profile-container">
|
||||
<div className="profile-header">
|
||||
<h2>{t("auth.profile", "Profile")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="profile-info">
|
||||
<p><strong>{t("auth.username", "Username")}:</strong> {user.username}</p>
|
||||
<div className="profile-user-section">
|
||||
<div className="profile-avatar-wrapper">
|
||||
{user.avatarUrl ? (
|
||||
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="profile-avatar-img" />
|
||||
) : (
|
||||
<div className="profile-avatar">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleAvatarUpload}
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<div className="profile-avatar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={avatarLoading}
|
||||
title={t("auth.uploadAvatar", "Upload avatar")}
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
{user.avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-btn danger"
|
||||
onClick={handleAvatarDelete}
|
||||
disabled={avatarLoading}
|
||||
title={t("auth.removeAvatar", "Remove avatar")}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="profile-username">{user.username}</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdate} className="profile-form">
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
{success && <div className="auth-success">{success}</div>}
|
||||
<div className="profile-section">
|
||||
<h3 className="profile-section-title">{t("auth.changePassword", "Change Password")}</h3>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
{success && <div className="auth-success">{success}</div>}
|
||||
|
||||
<h3>{t("auth.changePassword", "Change Password")}</h3>
|
||||
<div className="form-group">
|
||||
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? t("common.loading", "Loading...") : t("common.save", "Save")}
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={handleLogout}>
|
||||
{t("auth.logout", "Logout")}
|
||||
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
||||
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -259,7 +259,15 @@
|
||||
"passwordReset": "Passwort zurückgesetzt",
|
||||
"passwordResetSuccess": "Ihr Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...",
|
||||
"profileUpdated": "Profil erfolgreich aktualisiert",
|
||||
"rememberMe": "Angemeldet bleiben"
|
||||
"rememberMe": "Angemeldet bleiben",
|
||||
"localAccount": "Lokales Konto",
|
||||
"updatePassword": "Passwort ändern",
|
||||
"fillAllFields": "Bitte alle Passwortfelder ausfüllen",
|
||||
"signOut": "Abmelden",
|
||||
"uploadAvatar": "Avatar hochladen",
|
||||
"removeAvatar": "Avatar entfernen",
|
||||
"avatarUpdated": "Avatar aktualisiert",
|
||||
"avatarRemoved": "Avatar entfernt"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Wird geladen...",
|
||||
|
||||
@@ -261,7 +261,15 @@
|
||||
"passwordReset": "Password Reset",
|
||||
"passwordResetSuccess": "Your password has been reset. Redirecting to login...",
|
||||
"profileUpdated": "Profile updated successfully",
|
||||
"rememberMe": "Remember me"
|
||||
"rememberMe": "Remember me",
|
||||
"localAccount": "Local Account",
|
||||
"updatePassword": "Update Password",
|
||||
"fillAllFields": "Please fill in all password fields",
|
||||
"signOut": "Sign Out",
|
||||
"uploadAvatar": "Upload avatar",
|
||||
"removeAvatar": "Remove avatar",
|
||||
"avatarUpdated": "Avatar updated",
|
||||
"avatarRemoved": "Avatar removed"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
|
||||
+337
-14
@@ -493,6 +493,9 @@ textarea {
|
||||
}
|
||||
.day-divider.clickable { cursor: pointer; user-select: none; }
|
||||
.day-divider.clickable:hover { color: var(--accent); }
|
||||
/* Keep warning/danger colors on hover */
|
||||
.day-block.stock-warning .day-divider.clickable:hover { color: var(--warning); }
|
||||
.day-block.stock-danger .day-divider.clickable:hover { color: var(--danger); }
|
||||
.day-collapse-icon {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
@@ -2624,29 +2627,29 @@ h3 .reminder-icon.info-tooltip {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* User Menu Button in Header */
|
||||
/* User Menu Dropdown in Header */
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 100px;
|
||||
padding: 0.375rem 0.75rem;
|
||||
color: var(--text-primary);
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-btn:hover {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.user-menu-btn .user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
@@ -2654,7 +2657,148 @@ h3 .reminder-icon.info-tooltip {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu-btn .user-avatar-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu:hover .user-menu-btn .user-avatar,
|
||||
.user-menu:hover .user-menu-btn .user-avatar-img {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.75rem);
|
||||
right: 0;
|
||||
width: 260px;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.05);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px) scale(0.95);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-theme="light"] .user-dropdown {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.user-menu:hover .user-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(147, 51, 234, 0.1) 100%);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.dropdown-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown-avatar-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dropdown-username {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dropdown-role {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-item:hover svg {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dropdown-item.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.dropdown-item svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@@ -2669,8 +2813,187 @@ h3 .reminder-icon.info-tooltip {
|
||||
|
||||
/* Profile Modal */
|
||||
.profile-modal {
|
||||
max-width: 480px;
|
||||
max-width: 420px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-user-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.profile-avatar-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--text-tertiary);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-avatar-img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-avatar-actions {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.avatar-btn:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.avatar-btn.danger:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.avatar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.profile-form .form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.profile-form .form-group label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-form .form-group input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.profile-form .form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.profile-form .form-group input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.profile-actions .btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.profile-actions .btn-ghost {
|
||||
background: none;
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.profile-actions .btn-ghost:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-actions .btn-primary {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.profile-actions .btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.profile-actions .btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user