Polish UI, harden company creation, and add error pages
This commit is contained in:
@@ -2,9 +2,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Alert, Avatar, Box, Button, Chip, Divider, LinearProgress, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
||||
|
||||
import { api } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "external";
|
||||
@@ -15,6 +20,7 @@ type MeResponse = {
|
||||
lastName?: string;
|
||||
displayName?: string;
|
||||
profileCvText?: string;
|
||||
avatarImageDataUrl?: string;
|
||||
roles?: string[];
|
||||
googleLink?: {
|
||||
linked: boolean;
|
||||
@@ -23,6 +29,9 @@ type MeResponse = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
const CV_UPLOAD_ACCEPT = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
||||
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
|
||||
|
||||
function initialsFrom(values: Array<string | undefined>) {
|
||||
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
|
||||
if (joined.length === 0) return "?";
|
||||
@@ -36,10 +45,15 @@ function initialsFrom(values: Array<string | undefined>) {
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { t } = useI18n();
|
||||
const cvInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadingCv, setUploadingCv] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [userName, setUserName] = useState("");
|
||||
@@ -77,23 +91,102 @@ export default function ProfilePage() {
|
||||
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
|
||||
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
|
||||
|
||||
const providerLabel = me?.provider === "local" ? t("profileLocalAccount") : me?.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession");
|
||||
const googleLabel = me?.googleLink?.linked
|
||||
? me.googleLink.email
|
||||
? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email })
|
||||
: t("profileGoogleLinked")
|
||||
: t("profileGoogleNotLinked");
|
||||
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||
<CropImageDialog
|
||||
open={cropOpen}
|
||||
file={avatarFile}
|
||||
onClose={() => {
|
||||
setCropOpen(false);
|
||||
setAvatarFile(null);
|
||||
}}
|
||||
onSave={async (blob) => {
|
||||
const file = new File([blob], "avatar.png", { type: "image/png" });
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
setUploadingAvatar(true);
|
||||
try {
|
||||
const response = await api.post<{ avatarImageDataUrl?: string }>("/auth/avatar", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: response.data?.avatarImageDataUrl ?? prev.avatarImageDataUrl } : prev));
|
||||
setCropOpen(false);
|
||||
setAvatarFile(null);
|
||||
toast(t("profileImageUpdated"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error");
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Avatar sx={{ width: 64, height: 64, fontWeight: 900, fontSize: 24 }}>{initials}</Avatar>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept={AVATAR_UPLOAD_ACCEPT}
|
||||
style={{ display: "none" }}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
setAvatarFile(file);
|
||||
setCropOpen(true);
|
||||
}}
|
||||
/>
|
||||
<Button variant="outlined" size="small" startIcon={<PhotoCameraOutlinedIcon />} disabled={!isLocal || uploadingAvatar} onClick={() => avatarInputRef.current?.click()}>
|
||||
{uploadingAvatar ? t("profileUploading") : t("profileChangeImage")}
|
||||
</Button>
|
||||
{me?.avatarImageDataUrl ? (
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
color="inherit"
|
||||
startIcon={<DeleteOutlineIcon />}
|
||||
disabled={!isLocal || uploadingAvatar}
|
||||
onClick={async () => {
|
||||
setUploadingAvatar(true);
|
||||
try {
|
||||
await api.delete("/auth/avatar");
|
||||
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: undefined } : prev));
|
||||
toast(t("profileImageRemoved"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileImageRemoveFailed")), "error");
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("profileRemoveImage")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>
|
||||
Profile
|
||||
{t("profileTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>{me?.displayName || fullName || me?.userName || me?.email || "-"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || "Add a short headline to personalize your account view."}</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Chip label={me?.provider === "local" ? "Local account" : me?.provider === "google" ? "Google session" : "External session"} color={me?.provider === "local" ? "primary" : "default"} />
|
||||
<Chip label={me?.googleLink?.linked ? `Google linked${me.googleLink.email ? `: ${me.googleLink.email}` : ""}` : "Google not linked"} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
<Chip label={profileCvText.trim() ? `CV ready · ${cvWordCount} words` : "CV missing"} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
|
||||
<Chip label={providerLabel} color={me?.provider === "local" ? "primary" : "default"} />
|
||||
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -101,40 +194,40 @@ export default function ProfilePage() {
|
||||
|
||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="h6">Account</Typography>
|
||||
<Typography variant="h6">{t("profileAccountSection")}</Typography>
|
||||
{!isLocal ? (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
This session is not using a local app token, so profile edits are read-only right now.
|
||||
{t("profileReadOnlyInfo")}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<TextField label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label="Username" value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label="First name" value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label="Last name" value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label="Email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label={t("profileDisplayName")} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label={t("profileUsername")} value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label={t("profileFirstName")} value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label={t("profileLastName")} value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField
|
||||
label="Profile headline"
|
||||
label={t("profileHeadline")}
|
||||
value={headline}
|
||||
onChange={(e) => setHeadline(e.target.value)}
|
||||
helperText="Stored only in this browser to personalize your workspace."
|
||||
helperText={t("profileHeadlineHelp")}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">Master CV</Typography>
|
||||
<Typography variant="h6">{t("profileMasterCv")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Paste your resume text here or import a .txt/.md version. The app uses it to explain fit, gaps, interview talking points, and tailored messaging.
|
||||
{t("profileMasterCvBody")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
ref={cvInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,text/plain,text/markdown"
|
||||
accept={CV_UPLOAD_ACCEPT}
|
||||
style={{ display: "none" }}
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -146,28 +239,28 @@ export default function ProfilePage() {
|
||||
try {
|
||||
await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
await loadProfile();
|
||||
toast("CV text imported.", "success");
|
||||
toast(t("profileCvUploaded"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || "Failed to import CV text."), "error");
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
|
||||
} finally {
|
||||
setUploadingCv(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => fileInputRef.current?.click()}>
|
||||
{uploadingCv ? "Importing..." : "Import .txt/.md"}
|
||||
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => cvInputRef.current?.click()}>
|
||||
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
||||
</Button>
|
||||
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
||||
Copy CV text
|
||||
{t("profileCopyCvText")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
|
||||
<TextField
|
||||
label="Profile CV / master resume text"
|
||||
label={t("profileCvTextLabel")}
|
||||
value={profileCvText}
|
||||
onChange={(e) => setProfileCvText(e.target.value)}
|
||||
helperText="Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next."
|
||||
helperText={t("profileCvTextHelp")}
|
||||
multiline
|
||||
minRows={12}
|
||||
disabled={!isLocal}
|
||||
@@ -178,15 +271,12 @@ export default function ProfilePage() {
|
||||
{cvWordCount} words
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Tip: plain text works best right now.
|
||||
{t("profileCvPreferredUploads")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"}
|
||||
</Typography>
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || loading}
|
||||
@@ -196,27 +286,27 @@ export default function ProfilePage() {
|
||||
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText });
|
||||
window.localStorage.setItem("profileHeadline", headline.trim());
|
||||
await loadProfile();
|
||||
toast("Profile updated.", "success");
|
||||
toast(t("profileUpdated"), "success");
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || "Failed to update profile.";
|
||||
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
|
||||
toast(String(msg), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save changes
|
||||
{t("profileSaveChanges")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="h6">Change password</Typography>
|
||||
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>Password changes are only available for local accounts.</Typography> : null}
|
||||
<Typography variant="h6">{t("profileChangePassword")}</Typography>
|
||||
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
|
||||
</Box>
|
||||
|
||||
<TextField label="Current password" type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label="New password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label={t("profileCurrentPassword")} type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
@@ -228,16 +318,16 @@ export default function ProfilePage() {
|
||||
await api.post("/auth/change-password", { currentPassword, newPassword });
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
toast("Password updated.", "success");
|
||||
toast(t("profilePasswordUpdated"), "success");
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || "Failed to change password.";
|
||||
const msg = e?.response?.data || e?.message || t("profilePasswordUpdateFailed");
|
||||
toast(String(msg), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Update password
|
||||
{t("profileUpdatePassword")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user