460 lines
16 KiB
JavaScript
460 lines
16 KiB
JavaScript
import { useContext, useEffect, useRef, useState } from "react";
|
||
import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users";
|
||
import { SettingsContext } from "../context/SettingsContext";
|
||
import useActionToast from "../hooks/useActionToast";
|
||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||
import "../styles/pages/Settings.css";
|
||
|
||
|
||
export default function Settings() {
|
||
const { settings, updateSettings, resetSettings } = useContext(SettingsContext);
|
||
const toast = useActionToast();
|
||
const [activeTab, setActiveTab] = useState("appearance");
|
||
const tabsRef = useRef(null);
|
||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||
const [showRightArrow, setShowRightArrow] = useState(false);
|
||
|
||
// Account management state
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [currentPassword, setCurrentPassword] = useState("");
|
||
const [newPassword, setNewPassword] = useState("");
|
||
const [confirmPassword, setConfirmPassword] = useState("");
|
||
const [accountMessage, setAccountMessage] = useState({ type: "", text: "" });
|
||
const [loadingProfile, setLoadingProfile] = useState(false);
|
||
const [loadingPassword, setLoadingPassword] = useState(false);
|
||
|
||
// Load user profile
|
||
useEffect(() => {
|
||
const loadProfile = async () => {
|
||
try {
|
||
const response = await getCurrentUser();
|
||
setDisplayName(response.data.display_name || response.data.name || "");
|
||
} catch (error) {
|
||
console.error("Failed to load profile:", error);
|
||
}
|
||
};
|
||
loadProfile();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const tabsElement = tabsRef.current;
|
||
if (!tabsElement) return;
|
||
|
||
const updateArrowVisibility = () => {
|
||
const hasOverflow = tabsElement.scrollWidth > tabsElement.clientWidth + 1;
|
||
|
||
if (!hasOverflow) {
|
||
setShowLeftArrow(false);
|
||
setShowRightArrow(false);
|
||
return;
|
||
}
|
||
|
||
setShowLeftArrow(tabsElement.scrollLeft > 4);
|
||
setShowRightArrow(
|
||
tabsElement.scrollLeft + tabsElement.clientWidth < tabsElement.scrollWidth - 4
|
||
);
|
||
};
|
||
|
||
updateArrowVisibility();
|
||
|
||
tabsElement.addEventListener("scroll", updateArrowVisibility, { passive: true });
|
||
window.addEventListener("resize", updateArrowVisibility);
|
||
|
||
return () => {
|
||
tabsElement.removeEventListener("scroll", updateArrowVisibility);
|
||
window.removeEventListener("resize", updateArrowVisibility);
|
||
};
|
||
}, []);
|
||
|
||
const handleThemeChange = (theme) => {
|
||
updateSettings({ theme });
|
||
};
|
||
|
||
const handleUpdateDisplayName = async (e) => {
|
||
e.preventDefault();
|
||
setLoadingProfile(true);
|
||
setAccountMessage({ type: "", text: "" });
|
||
|
||
try {
|
||
await updateCurrentUser(displayName);
|
||
setAccountMessage({ type: "success", text: "Display name updated successfully!" });
|
||
toast.success("Updated display name", `Updated display name to ${displayName}`);
|
||
} catch (error) {
|
||
const message = getApiErrorMessage(error, "Failed to update display name");
|
||
setAccountMessage({
|
||
type: "error",
|
||
text: message
|
||
});
|
||
toast.error("Update display name failed", `Update display name failed: ${message}`);
|
||
} finally {
|
||
setLoadingProfile(false);
|
||
}
|
||
};
|
||
|
||
const handleChangePassword = async (e) => {
|
||
e.preventDefault();
|
||
setLoadingPassword(true);
|
||
setAccountMessage({ type: "", text: "" });
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
setAccountMessage({ type: "error", text: "New passwords don't match" });
|
||
setLoadingPassword(false);
|
||
return;
|
||
}
|
||
|
||
if (newPassword.length < 6) {
|
||
setAccountMessage({ type: "error", text: "Password must be at least 6 characters" });
|
||
setLoadingPassword(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await changePassword(currentPassword, newPassword);
|
||
setAccountMessage({ type: "success", text: "Password changed successfully!" });
|
||
toast.success("Changed password", "Changed password successfully");
|
||
setCurrentPassword("");
|
||
setNewPassword("");
|
||
setConfirmPassword("");
|
||
} catch (error) {
|
||
const message = getApiErrorMessage(error, "Failed to change password");
|
||
setAccountMessage({
|
||
type: "error",
|
||
text: message
|
||
});
|
||
toast.error("Change password failed", `Change password failed: ${message}`);
|
||
} finally {
|
||
setLoadingPassword(false);
|
||
}
|
||
};
|
||
|
||
|
||
const handleToggle = (key) => {
|
||
updateSettings({ [key]: !settings[key] });
|
||
};
|
||
|
||
|
||
const handleNumberChange = (key, value) => {
|
||
updateSettings({ [key]: parseInt(value, 10) });
|
||
};
|
||
|
||
const handleReset = () => {
|
||
if (window.confirm("Reset all settings to defaults?")) {
|
||
resetSettings();
|
||
}
|
||
};
|
||
|
||
|
||
return (
|
||
<div className="settings-page">
|
||
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||
<h1 className="text-2xl font-semibold mb-4">Settings</h1>
|
||
|
||
<div className="settings-tabs-wrapper">
|
||
<div
|
||
className={`settings-tabs-arrow settings-tabs-arrow-left ${showLeftArrow ? "visible" : ""}`}
|
||
aria-hidden="true"
|
||
>
|
||
‹
|
||
</div>
|
||
|
||
<div className="settings-tabs" ref={tabsRef}>
|
||
<button
|
||
className={`settings-tab ${activeTab === "appearance" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("appearance")}
|
||
>
|
||
Appearance
|
||
</button>
|
||
<button
|
||
className={`settings-tab ${activeTab === "list" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("list")}
|
||
>
|
||
List Display
|
||
</button>
|
||
<button
|
||
className={`settings-tab ${activeTab === "behavior" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("behavior")}
|
||
>
|
||
Behavior
|
||
</button>
|
||
<button
|
||
className={`settings-tab ${activeTab === "account" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("account")}
|
||
>
|
||
Account
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
className={`settings-tabs-arrow settings-tabs-arrow-right ${showRightArrow ? "visible" : ""}`}
|
||
aria-hidden="true"
|
||
>
|
||
›
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-content">
|
||
{/* Appearance Tab */}
|
||
{activeTab === "appearance" && (
|
||
<div className="settings-section">
|
||
<h2 className="text-xl font-semibold mb-4">Appearance</h2>
|
||
|
||
<div className="settings-group">
|
||
<label className="settings-label">Theme</label>
|
||
<div className="settings-theme-options">
|
||
<button
|
||
className={`settings-theme-btn ${settings.theme === "light" ? "active" : ""}`}
|
||
onClick={() => handleThemeChange("light")}
|
||
>
|
||
☀️ Light
|
||
</button>
|
||
<button
|
||
className={`settings-theme-btn ${settings.theme === "dark" ? "active" : ""}`}
|
||
onClick={() => handleThemeChange("dark")}
|
||
>
|
||
🌙 Dark
|
||
</button>
|
||
<button
|
||
className={`settings-theme-btn ${settings.theme === "auto" ? "active" : ""}`}
|
||
onClick={() => handleThemeChange("auto")}
|
||
>
|
||
🔄 Auto
|
||
</button>
|
||
</div>
|
||
<p className="settings-description">
|
||
Auto mode follows your system preferences
|
||
</p>
|
||
</div>
|
||
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.compactView}
|
||
onChange={() => handleToggle("compactView")}
|
||
/>
|
||
<span>Compact View</span>
|
||
</label>
|
||
<p className="settings-description">
|
||
Show more items on screen with reduced spacing
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* List Display Tab */}
|
||
{activeTab === "list" && (
|
||
<div className="settings-section">
|
||
<h2 className="text-xl font-semibold mb-4">List Display</h2>
|
||
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.showRecentlyBought}
|
||
onChange={() => handleToggle("showRecentlyBought")}
|
||
/>
|
||
<span>Show Recently Bought Section</span>
|
||
</label>
|
||
<p className="settings-description">
|
||
Display items bought in the last 24 hours
|
||
</p>
|
||
</div>
|
||
|
||
{settings.showRecentlyBought && (
|
||
<>
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
Recently Bought Item Count: {settings.recentlyBoughtCount}
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="5"
|
||
max="50"
|
||
step="5"
|
||
value={settings.recentlyBoughtCount}
|
||
onChange={(e) => handleNumberChange("recentlyBoughtCount", e.target.value)}
|
||
className="settings-range"
|
||
/>
|
||
<p className="settings-description">
|
||
Number of items to show initially (5-50)
|
||
</p>
|
||
</div>
|
||
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.recentlyBoughtCollapsed}
|
||
onChange={() => handleToggle("recentlyBoughtCollapsed")}
|
||
/>
|
||
<span>Collapse Recently Bought by Default</span>
|
||
</label>
|
||
<p className="settings-description">
|
||
Start with the section collapsed
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Behavior Tab */}
|
||
{activeTab === "behavior" && (
|
||
<div className="settings-section">
|
||
<h2 className="text-xl font-semibold mb-4">Behavior</h2>
|
||
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.confirmBeforeBuy}
|
||
onChange={() => handleToggle("confirmBeforeBuy")}
|
||
/>
|
||
<span>Confirm Before Buying</span>
|
||
</label>
|
||
<p className="settings-description">
|
||
Show confirmation modal when marking items as bought
|
||
</p>
|
||
</div>
|
||
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
Auto-reload Interval (minutes): {settings.autoReloadInterval || "Disabled"}
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="30"
|
||
step="5"
|
||
value={settings.autoReloadInterval}
|
||
onChange={(e) => handleNumberChange("autoReloadInterval", e.target.value)}
|
||
className="settings-range"
|
||
/>
|
||
<p className="settings-description">
|
||
Automatically refresh the list every X minutes (0 = disabled)
|
||
</p>
|
||
</div>
|
||
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.hapticFeedback}
|
||
onChange={() => handleToggle("hapticFeedback")}
|
||
/>
|
||
<span>Haptic Feedback (Mobile)</span>
|
||
</label>
|
||
<p className="settings-description">
|
||
Vibrate on long-press and other interactions
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Account Tab */}
|
||
{activeTab === "account" && (
|
||
<div className="settings-section">
|
||
<h2 className="text-xl font-semibold mb-4">Account Management</h2>
|
||
|
||
{accountMessage.text && (
|
||
<div className={`account-message ${accountMessage.type}`}>
|
||
{accountMessage.text}
|
||
</div>
|
||
)}
|
||
|
||
{/* Display Name Section */}
|
||
<form onSubmit={handleUpdateDisplayName} className="account-form">
|
||
<h3 className="text-lg font-semibold mb-3">Display Name</h3>
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
Display Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
maxLength={100}
|
||
className="form-input"
|
||
placeholder="Your display name"
|
||
/>
|
||
<p className="settings-description">
|
||
{displayName.length}/100 characters
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
className="btn btn-primary"
|
||
disabled={loadingProfile}
|
||
>
|
||
{loadingProfile ? "Saving..." : "Save Display Name"}
|
||
</button>
|
||
</form>
|
||
|
||
<hr className="my-4" style={{ borderColor: 'var(--border-color)' }} />
|
||
|
||
{/* Password Change Section */}
|
||
<form onSubmit={handleChangePassword} className="account-form">
|
||
<h3 className="text-lg font-semibold mb-3">Change Password</h3>
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
Current Password
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={currentPassword}
|
||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||
className="form-input"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
New Password
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
className="form-input"
|
||
minLength={6}
|
||
required
|
||
/>
|
||
<p className="settings-description">
|
||
Minimum 6 characters
|
||
</p>
|
||
</div>
|
||
<div className="settings-group">
|
||
<label className="settings-label">
|
||
Confirm New Password
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
className="form-input"
|
||
minLength={6}
|
||
required
|
||
/>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
className="btn btn-primary"
|
||
disabled={loadingPassword}
|
||
>
|
||
{loadingPassword ? "Changing..." : "Change Password"}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<button onClick={handleReset} className="btn btn-outline">
|
||
Reset to Defaults
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|