added ability to change user info
This commit is contained in:
parent
cddd341bb2
commit
40d49d5e97
22 changed files with 799 additions and 26 deletions
|
|
@ -94,6 +94,10 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI)))
|
||||
mux.Handle("POST /api/actions/set-support", s.requireAuthAPI(http.HandlerFunc(s.handleSetSupportAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-profile", s.requireAuthAPI(http.HandlerFunc(s.handleSetProfileAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-phone", s.requireAuthAPI(http.HandlerFunc(s.handleSetPhoneAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountAvatarAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-login-email", s.requireAuthAPI(http.HandlerFunc(s.handleSetLoginEmailAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
|
||||
|
|
@ -1153,6 +1157,118 @@ func (s *server) handleSetUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setProfileAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetProfileAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setProfileAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetProfileRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-profile"),
|
||||
UserID: body.UserID,
|
||||
FirstName: body.FirstName,
|
||||
LastName: body.LastName,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-profile", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setPhoneAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetPhoneAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setPhoneAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetPhoneRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-phone"),
|
||||
UserID: body.UserID,
|
||||
Phone: body.Phone,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-phone", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setLoginEmailAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetLoginEmailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setLoginEmailAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetLoginEmailRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-login-email"),
|
||||
UserID: body.UserID,
|
||||
Email: body.Email,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-login-email", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setAccountAvatarAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetAccountAvatarAPI(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body setAccountAvatarAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "avatar file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
|
||||
writeAPIError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
||||
return
|
||||
}
|
||||
req := admin.SetAccountAvatarRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-avatar"),
|
||||
UserID: body.UserID,
|
||||
FileName: header.Filename,
|
||||
}
|
||||
result, err := s.callAdminMultipart(r.Context(), "/v1/accounts/set-avatar", req, header.Filename, data)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setUserColorAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
|
|||
1
cmd/telesrv-admin/web/dist/assets/index-BpSP1ojC.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-BpSP1ojC.css
vendored
Normal file
File diff suppressed because one or more lines are too long
10
cmd/telesrv-admin/web/dist/assets/index-CKkpOZxZ.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-CKkpOZxZ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-DAm5-kIE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D5eZOKdH.css">
|
||||
<script type="module" crossorigin src="/assets/index-CKkpOZxZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ export const api = {
|
|||
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
|
||||
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
|
||||
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
|
||||
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
|
||||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
|
||||
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),
|
||||
|
|
|
|||
88
cmd/telesrv-admin/web/src/components/AccountAvatarModal.tsx
Normal file
88
cmd/telesrv-admin/web/src/components/AccountAvatarModal.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { ImagePlus, Loader2, Upload, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert } from "./ui";
|
||||
|
||||
// AccountAvatarModal uploads a new profile photo for an account. It follows
|
||||
// CreateStickerSetModal's shape (a single reason field + direct execute,
|
||||
// rather than ActionButton's JSON dry-run/confirm flow) since the payload is
|
||||
// a multipart file upload, not a plain JSON body.
|
||||
export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number; onClose: () => void; onDone: () => void }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewURL, setPreviewURL] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setPreviewURL("");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewURL(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
async function submit() {
|
||||
if (!file) {
|
||||
setError("Choose an image file first.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, user_id: userID }));
|
||||
form.set("file", file, file.name);
|
||||
const result = await api.setAccountAvatar(form);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change avatar"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Account"}</div>
|
||||
<h2>{"Change avatar"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />}
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{"New avatar"}</span><strong>{file ? file.name : "Choose a JPEG, PNG, or WebP image"}</strong></span>
|
||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||
</label>
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
|
||||
<button className="btn primary" type="button" onClick={submit} disabled={busy}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
|
||||
{"Upload avatar"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -39,19 +39,24 @@ export function Avatar({
|
|||
firstName,
|
||||
lastName,
|
||||
username = "",
|
||||
size = 34
|
||||
size = 34,
|
||||
refreshKey
|
||||
}: {
|
||||
userID: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
username?: string;
|
||||
size?: number;
|
||||
// refreshKey busts the browser's cached image (Cache-Control: max-age=300)
|
||||
// right after an admin-driven avatar change, so the new photo shows up
|
||||
// immediately instead of the stale cached one.
|
||||
refreshKey?: number | string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [userID]);
|
||||
}, [userID, refreshKey]);
|
||||
|
||||
if (failed) {
|
||||
const [from, to] = avatarGradient(userID);
|
||||
|
|
@ -68,7 +73,7 @@ export function Avatar({
|
|||
return (
|
||||
<img
|
||||
className="avatar-photo-img"
|
||||
src={`/api/accounts/${userID}/avatar`}
|
||||
src={`/api/accounts/${userID}/avatar${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: size, height: size }}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
|
||||
import { AtSign, LifeBuoy, Mail, Palette, Phone, Settings2, Smile, UserRound } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActionButton } from "./ActionButton";
|
||||
import { toInt } from "../lib/format";
|
||||
|
|
@ -47,6 +47,92 @@ export function UsernameAction({ idKey, id, path, current, onDone }: {
|
|||
);
|
||||
}
|
||||
|
||||
// ProfileNameAction force-sets a user's first and last name.
|
||||
export function ProfileNameAction({ id, path, currentFirstName, currentLastName, onDone }: {
|
||||
id: number;
|
||||
path: string;
|
||||
currentFirstName: string;
|
||||
currentLastName: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [firstName, setFirstName] = useState(currentFirstName);
|
||||
const [lastName, setLastName] = useState(currentLastName);
|
||||
return (
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
<span>{"First name"}</span>
|
||||
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="First name" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Last name"}</span>
|
||||
<input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Last name" />
|
||||
</label>
|
||||
<ActionButton
|
||||
label={"Set name"}
|
||||
icon={<UserRound size={15} />}
|
||||
tone="neutral"
|
||||
path={path}
|
||||
payload={() => ({ user_id: id, first_name: firstName.trim(), last_name: lastName.trim() })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PhoneAction force-sets a user's phone number. The backend rejects a value
|
||||
// already tied to another account.
|
||||
export function PhoneAction({ id, path, current, onDone }: {
|
||||
id: number;
|
||||
path: string;
|
||||
current: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [phone, setPhone] = useState(current);
|
||||
return (
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
<span>{"Phone number"}</span>
|
||||
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="15551234567" />
|
||||
</label>
|
||||
<ActionButton
|
||||
label={"Set phone"}
|
||||
icon={<Phone size={15} />}
|
||||
tone="warn"
|
||||
path={path}
|
||||
payload={() => ({ user_id: id, phone: phone.trim() })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// LoginEmailAction sets, or (left empty) clears, the login/signup email
|
||||
// factor. The backend rejects an email already tied to another account.
|
||||
export function LoginEmailAction({ id, path, current, onDone }: {
|
||||
id: number;
|
||||
path: string;
|
||||
current: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState(current);
|
||||
return (
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
<span>{"Login email"}</span>
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@example.com (empty clears it)" type="email" />
|
||||
</label>
|
||||
<ActionButton
|
||||
label={email.trim() ? "Set login email" : "Clear login email"}
|
||||
icon={<Mail size={15} />}
|
||||
tone="warn"
|
||||
path={path}
|
||||
payload={() => ({ user_id: id, email: email.trim() })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ColorAction sets or clears a name/profile color (Layer 228 peer color).
|
||||
export function ColorAction({ idKey, id, path, onDone }: {
|
||||
idKey: IDKey;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { ArrowLeft, BadgeCheck, CircleAlert, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
|
||||
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { AccountAvatarModal } from "../components/AccountAvatarModal";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { AuthorizationTable } from "../components/AuthorizationTable";
|
||||
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary, UsernameCell } from "../components/ui";
|
||||
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
||||
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
|
||||
import { ColorAction, EmojiStatusAction, LoginEmailAction, PhoneAction, ProfileNameAction, SupportAction, UsernameAction } from "../components/attributes";
|
||||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountDetail } from "../types";
|
||||
|
|
@ -22,6 +23,8 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
const [starsAmount, setStarsAmount] = useState("1000");
|
||||
const [freezeUntil, setFreezeUntil] = useState(() => toDateTimeLocal(new Date(Date.now() + 7 * 86400_000)));
|
||||
const [freezeAppealURL, setFreezeAppealURL] = useState("");
|
||||
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
|
||||
const [avatarVersion, setAvatarVersion] = useState(0);
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
|
|
@ -70,7 +73,18 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
>
|
||||
<section className="entity-head">
|
||||
<div className="entity-head-main">
|
||||
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} />
|
||||
<div className="avatar-edit-slot">
|
||||
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} refreshKey={avatarVersion || undefined} />
|
||||
<button
|
||||
className="icon-btn avatar-edit-btn"
|
||||
type="button"
|
||||
aria-label={"Change avatar"}
|
||||
title={"Change avatar"}
|
||||
onClick={() => setAvatarModalOpen(true)}
|
||||
>
|
||||
<ImagePlus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="entity-title">{displayName(account)}</div>
|
||||
<div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div>
|
||||
|
|
@ -257,6 +271,21 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Name"} />
|
||||
<ProfileNameAction id={account.ID} path="/api/actions/set-account-profile" currentFirstName={account.FirstName} currentLastName={account.LastName} onDone={load} />
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Phone Number"} />
|
||||
<PhoneAction id={account.ID} path="/api/actions/set-account-phone" current={account.Phone} onDone={load} />
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Login Email"} text={"The email used for sign-in / password-recovery, not a contact address."} />
|
||||
<LoginEmailAction id={account.ID} path="/api/actions/set-account-login-email" current={account.LoginEmail} onDone={load} />
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Profile Color"} />
|
||||
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />
|
||||
|
|
@ -274,6 +303,17 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{avatarModalOpen && (
|
||||
<AccountAvatarModal
|
||||
userID={account.ID}
|
||||
onClose={() => setAvatarModalOpen(false)}
|
||||
onDone={() => {
|
||||
setAvatarVersion((v) => v + 1);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,25 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-edit-slot {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-edit-btn {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: var(--brand);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .2);
|
||||
}
|
||||
.avatar-edit-btn:hover { background: var(--brand-tint); border-color: var(--brand); }
|
||||
|
||||
.entity-title {
|
||||
color: var(--heading);
|
||||
font-size: 20px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue