- Reduce server-side limit to 5MB for upload endpoint - Increase client-side resizing threshold to 4MB - Add specific error handling for 413 responses - Add more detailed logging for file sizes - Improve user error messages Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
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<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: 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<string | null>(null);
|
|
|
|
// 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()
|
|
});
|
|
|
|
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 (
|
|
<>
|
|
<section style={{ marginBottom: 32 }}>
|
|
<h2 style={{ fontSize: 13, color: "var(--text-muted)", fontFamily: "monospace", letterSpacing: 1, 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: 13, color: "var(--text-muted)", fontFamily: "monospace", letterSpacing: 1, 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: 13, color: "var(--text-muted)", fontFamily: "monospace", letterSpacing: 1, marginBottom: 16 }}>AVATAR</h2>
|
|
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 16 }}>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={async (e) => {
|
|
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<MemberRead>('/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"
|
|
/>
|
|
<label htmlFor="avatar-upload" style={{
|
|
background: "var(--accent)",
|
|
border: "none",
|
|
borderRadius: 6,
|
|
color: "var(--accent-fg)",
|
|
cursor: "pointer",
|
|
padding: "8px 16px",
|
|
fontWeight: 600,
|
|
fontSize: 14
|
|
}}>
|
|
{uploading ? "Uploading..." : "Upload Avatar"}
|
|
</label>
|
|
<button
|
|
onClick={async () => {
|
|
console.log("Generate Random button clicked");
|
|
try {
|
|
// Generate a new random avatar using user ID as seed for consistency
|
|
const seed = Math.random().toString(36).substring(2, 15);
|
|
const newAvatarUrl = `https://api.dicebear.com/v6/identicon/svg?seed=${seed}&backgroundType=gradientLinear&size=128`;
|
|
|
|
console.log("Generated avatar URL:", newAvatarUrl);
|
|
console.log("Calling updateSettings with:", { avatar_url: newAvatarUrl });
|
|
|
|
await updateSettings({ avatar_url: newAvatarUrl });
|
|
setAvatarUrl(newAvatarUrl);
|
|
qc.invalidateQueries({ queryKey: ["me"] });
|
|
|
|
console.log("Avatar updated successfully");
|
|
} catch (err) {
|
|
console.error("Failed to update avatar:", 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: 14
|
|
}}
|
|
>
|
|
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={() => {
|
|
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`);
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
await updateSettings({ avatar_url: undefined });
|
|
setAvatarUrl("");
|
|
qc.invalidateQueries({ queryKey: ["me"] });
|
|
} 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={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 24 }}>
|
|
<div style={{ maxWidth: 540, margin: "0 auto" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 32 }}>
|
|
<button
|
|
onClick={() => navigate("/")}
|
|
style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 13, padding: 0 }}
|
|
>
|
|
← All Bands
|
|
</button>
|
|
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: 0, fontSize: 20 }}>Settings</h1>
|
|
</div>
|
|
|
|
{isLoading && <p style={{ color: "var(--text-muted)" }}>Loading...</p>}
|
|
{me && <SettingsForm me={me} onBack={() => navigate("/")} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|