import { useState } 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("/auth/me"); const updateSettings = (data: { display_name?: string; nc_url?: string; nc_username?: string; nc_password?: string; avatar_url?: string; }) => api.patch("/auth/me/settings", data); const inputStyle: React.CSSProperties = { width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, 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(null); // Image resizing function const resizeImage = (file: File, maxWidth: number, maxHeight: number): Promise => { 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() }); console.log(`Resized image from ${img.width}x${img.height} to ${width}x${height}`); console.log(`File size reduced from ${file.size} to ${resizedFile.size} bytes`); 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 ( <>

PROFILE

{avatarUrl && ( Profile )}
setDisplayName(e.target.value)} style={inputStyle} />

{me.email}

NEXTCLOUD CONNECTION

Configure your personal Nextcloud credentials. When set, all file operations (band folders, song uploads, scans) will use these credentials.

{me.nc_configured ? "Connected" : "Not configured"}
setNcUrl(e.target.value)} placeholder="https://cloud.example.com" style={inputStyle} /> setNcUsername(e.target.value)} style={inputStyle} /> setNcPassword(e.target.value)} placeholder={me.nc_configured ? "•••••••• (leave blank to keep existing)" : ""} style={inputStyle} />

Use an app password from Nextcloud Settings → Security for better security.

AVATAR

{ console.log("File input changed", e.target.files); const file = e.target.files?.[0]; if (file) { console.log("Selected file:", file.name, file.type, file.size); 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) { console.log("File too large, resizing..."); processedFile = await resizeImage(file, 800, 800); // Max 800x800 } const formData = new FormData(); formData.append('file', processedFile, processedFile.name || file.name); console.log("Uploading file to /auth/me/avatar"); console.log("Final file size:", processedFile.size); const response = await api.post('/auth/me/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); console.log("Upload response:", response); setAvatarUrl(response.avatar_url || ''); qc.invalidateQueries({ queryKey: ['me'] }); } catch (err) { console.error("Upload failed:", err); if (err instanceof Error && err.message.includes('413')) { setError('File too large. Maximum size is 5MB. Please choose a smaller image.'); } else { setError(err instanceof Error ? err.message : 'Failed to upload avatar'); } } finally { setUploading(false); } } }} style={{ display: "none" }} id="avatar-upload" />
{avatarUrl && (
Preview { console.error("Failed to load avatar:", avatarUrl); // Set to default avatar on error setAvatarUrl(`https://api.dicebear.com/v6/identicon/svg?seed=${me.id}&backgroundType=gradientLinear&size=128`); }} />
)}
{error &&

{error}

} {saved &&

Settings saved.

}
); } export function SettingsPage() { const navigate = useNavigate(); const { data: me, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe }); return (

Settings

{isLoading &&

Loading...

} {me && navigate("/")} />}
); }