Files
rehearshalhub/web/src/pages/SettingsPage.tsx
Mistral Vibe 6dc191585c style: align all pages with CLAUDE.md design system
- Inputs: uniform padding (8px 12px), borderRadius 7, bg-inset background
- List rows/cards: bg-subtle background, border-subtle border (bg-inset was input-only)
- Invite/admin badge borders: use accent-border var instead of raw accent
- Section headers: 11px, weight 500, uppercase, 0.7px letter-spacing
- Notification/status banners: borderRadius 8
- Remove debug console.log statements from SettingsPage avatar upload flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:52:06 +02:00

355 lines
13 KiB
TypeScript

import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
interface MemberRead {
id: string;
display_name: string;
email: string;
avatar_url: string | null;
nc_username: string | null;
nc_url: string | null;
nc_configured: boolean;
}
const getMe = () => api.get<MemberRead>("/auth/me");
const updateSettings = (data: {
display_name?: string;
nc_url?: string;
nc_username?: string;
nc_password?: string;
avatar_url?: string;
}) => api.patch<MemberRead>("/auth/me/settings", data);
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
background: "var(--bg-inset)",
border: "1px solid var(--border)",
borderRadius: 7,
color: "var(--text)",
fontSize: 14,
boxSizing: "border-box",
};
function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) {
const qc = useQueryClient();
const [displayName, setDisplayName] = useState(me.display_name ?? "");
const [ncUrl, setNcUrl] = useState(me.nc_url ?? "");
const [ncUsername, setNcUsername] = useState(me.nc_username ?? "");
const [ncPassword, setNcPassword] = useState("");
const [avatarUrl, setAvatarUrl] = useState(me.avatar_url ?? "");
const [uploading, setUploading] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
// Keep local avatarUrl in sync when the server-side value changes (e.g. after
// a background refetch or a change made on another device).
useEffect(() => {
setAvatarUrl(me.avatar_url ?? "");
}, [me.avatar_url]);
// Image resizing function
const resizeImage = (file: File, maxWidth: number, maxHeight: number): Promise<File> => {
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
reader.onload = (event) => {
if (typeof event.target?.result !== 'string') {
reject(new Error('Failed to read file'));
return;
}
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
// Calculate new dimensions
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to create blob'));
return;
}
const resizedFile = new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now()
});
resolve(resizedFile);
}, 'image/jpeg', 0.8); // JPEG quality 80%
};
img.onerror = reject;
img.src = event.target?.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const saveMutation = useMutation({
mutationFn: () =>
updateSettings({
display_name: displayName || undefined,
nc_url: ncUrl || undefined,
nc_username: ncUsername || undefined,
nc_password: ncPassword || undefined,
avatar_url: avatarUrl || undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
setSaved(true);
setNcPassword("");
setError(null);
setTimeout(() => setSaved(false), 3000);
},
onError: (err) => setError(err instanceof Error ? err.message : "Save failed"),
});
const labelStyle: React.CSSProperties = {
display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6,
};
return (
<>
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 11, fontWeight: 500, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.7px", marginBottom: 16 }}>PROFILE</h2>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 16 }}>
{avatarUrl && (
<img src={avatarUrl} alt="Profile" style={{ width: 64, height: 64, borderRadius: "50%", objectFit: "cover" }} />
)}
<div style={{ flex: 1 }}>
<label style={labelStyle}>DISPLAY NAME</label>
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={inputStyle} />
<p style={{ color: "var(--text-subtle)", fontSize: 11, margin: "4px 0 0" }}>{me.email}</p>
</div>
</div>
</section>
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 11, fontWeight: 500, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.7px", marginBottom: 8 }}>Nextcloud Connection</h2>
<p style={{ color: "var(--text-subtle)", fontSize: 12, marginBottom: 16 }}>
Configure your personal Nextcloud credentials. When set, all file operations (band folders, song uploads, scans) will use these credentials.
</p>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 16 }}>
<span style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: me.nc_configured ? "var(--teal)" : "var(--text-muted)" }} />
<span style={{ fontSize: 12, color: me.nc_configured ? "var(--teal)" : "var(--text-muted)" }}>
{me.nc_configured ? "Connected" : "Not configured"}
</span>
</div>
<label style={labelStyle}>NEXTCLOUD URL</label>
<input value={ncUrl} onChange={(e) => setNcUrl(e.target.value)} placeholder="https://cloud.example.com" style={inputStyle} />
<label style={{ ...labelStyle, marginTop: 12 }}>USERNAME</label>
<input value={ncUsername} onChange={(e) => setNcUsername(e.target.value)} style={inputStyle} />
<label style={{ ...labelStyle, marginTop: 12 }}>PASSWORD / APP PASSWORD</label>
<input
type="password"
value={ncPassword}
onChange={(e) => setNcPassword(e.target.value)}
placeholder={me.nc_configured ? "•••••••• (leave blank to keep existing)" : ""}
style={inputStyle}
/>
<p style={{ color: "var(--text-subtle)", fontSize: 11, margin: "4px 0 0" }}>
Use an app password from Nextcloud Settings Security for better security.
</p>
</section>
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 11, fontWeight: 500, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.7px", marginBottom: 16 }}>AVATAR</h2>
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 16 }}>
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
setUploading(true);
try {
// Check file size and resize if needed
const maxSize = 4 * 1024 * 1024; // 4MB (more conservative to account for base64 overhead)
let processedFile = file;
if (file.size > maxSize) {
processedFile = await resizeImage(file, 800, 800); // Max 800x800
}
const formData = new FormData();
formData.append('file', processedFile, processedFile.name || file.name);
const response = await api.upload<MemberRead>('/auth/me/avatar', formData);
setAvatarUrl(response.avatar_url || '');
qc.invalidateQueries({ queryKey: ['me'] });
qc.invalidateQueries({ queryKey: ['comments'] });
} catch (err) {
let errorMessage = 'Failed to upload avatar. Please try again.';
if (err instanceof Error) {
errorMessage = err.message;
if (err.message.includes('413')) {
errorMessage = 'File too large. Maximum size is 5MB. Please choose a smaller image.';
} else if (err.message.includes('422')) {
errorMessage = 'Invalid image file. Please upload a valid image (JPG, PNG, etc.).';
}
} else if (typeof err === 'object' && err !== null) {
const errorObj = err as { status?: number; data?: { detail?: string } };
if (errorObj.status === 422 && errorObj.data?.detail) {
errorMessage = errorObj.data.detail;
}
}
setError(errorMessage);
} finally {
setUploading(false);
}
}
}}
style={{ display: "none" }}
id="avatar-upload"
/>
<label htmlFor="avatar-upload" style={{
background: "var(--accent)",
border: "none",
borderRadius: 6,
color: "var(--accent-fg)",
cursor: "pointer",
padding: "8px 16px",
fontWeight: 600,
fontSize: 13,
}}>
{uploading ? "Uploading..." : "Upload Avatar"}
</label>
<button
onClick={async () => {
try {
const seed = Math.random().toString(36).substring(2, 15);
const newAvatarUrl = `https://api.dicebear.com/9.x/identicon/svg?seed=${seed}&backgroundType=gradientLinear&size=128`;
await updateSettings({ avatar_url: newAvatarUrl });
setAvatarUrl(newAvatarUrl);
qc.invalidateQueries({ queryKey: ["me"] });
qc.invalidateQueries({ queryKey: ["comments"] });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update avatar');
}
}}
style={{
background: "none",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text)",
cursor: "pointer",
padding: "8px 16px",
fontSize: 13,
}}
>
Generate Random
</button>
</div>
{avatarUrl && (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<img
src={avatarUrl}
alt="Preview"
style={{ width: 48, height: 48, borderRadius: "50%", objectFit: "cover" }}
onError={() => {
setAvatarUrl(`https://api.dicebear.com/9.x/identicon/svg?seed=${me.id}&backgroundType=gradientLinear&size=128`);
}}
/>
<button
onClick={async () => {
try {
await updateSettings({ avatar_url: "" });
setAvatarUrl("");
qc.invalidateQueries({ queryKey: ["me"] });
qc.invalidateQueries({ queryKey: ["comments"] });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove avatar');
}
}}
style={{
background: "none",
border: "none",
color: "var(--danger)",
cursor: "pointer",
fontSize: 12
}}
>
Remove
</button>
</div>
)}
</section>
{error && <p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>}
{saved && <p style={{ color: "var(--teal)", fontSize: 13, marginBottom: 12 }}>Settings saved.</p>}
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "10px 24px", fontWeight: 600, fontSize: 14 }}
>
{saveMutation.isPending ? "Saving…" : "Save Settings"}
</button>
<button
onClick={onBack}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "10px 18px", fontSize: 14 }}
>
Cancel
</button>
</div>
</>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const { data: me, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe });
return (
<div style={{ padding: 24 }}>
<div style={{ maxWidth: 540, margin: "0 auto" }}>
<h1 style={{ color: "var(--text)", margin: "0 0 24px", fontSize: 17, fontWeight: 500 }}>
Settings
</h1>
{isLoading && <p style={{ color: "var(--text-muted)" }}>Loading...</p>}
{me && <SettingsForm me={me} onBack={() => navigate(-1)} />}
</div>
</div>
);
}