v3: added onboarding email on account creation
This commit is contained in:
111
src/App.css
111
src/App.css
@@ -1144,7 +1144,7 @@ body.has-player .fab-new {
|
||||
/* ── Public profile page ── */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -1262,6 +1262,100 @@ body.has-player .fab-new {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-email-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.1rem 0 0.4rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.2rem;
|
||||
margin-left: -0.2rem;
|
||||
}
|
||||
|
||||
.profile-email-display:hover {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.profile-email-display:hover .profile-description-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.profile-email-editor {
|
||||
margin: 0.2rem 0 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.profile-email-input {
|
||||
width: min(260px, 100%);
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--color-border);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.profile-email-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.profile-email-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.profile-email-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.75rem;
|
||||
padding: 0 0.65rem;
|
||||
border-radius: 5px;
|
||||
border: 1.5px solid;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.profile-email-btn--save {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.profile-email-btn--save:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
border-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.profile-email-btn--save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.profile-email-btn--cancel {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.profile-email-btn--cancel:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.profile-description-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1394,12 +1488,16 @@ body.has-player .fab-new {
|
||||
.btn-border-danger,
|
||||
.btn-border-success,
|
||||
.btn-border {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
@@ -1594,7 +1692,8 @@ body.has-player .fab-new {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@@ -3729,8 +3828,12 @@ body.has-player .fab-new {
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: max-width 0.25s ease, opacity 0.2s ease, padding 0.25s ease,
|
||||
border-width 0.25s ease, border-color 0.15s;
|
||||
transition:
|
||||
max-width 0.25s ease,
|
||||
opacity 0.2s ease,
|
||||
padding 0.25s ease,
|
||||
border-width 0.25s ease,
|
||||
border-color 0.15s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useLocation, useNavigate } from "react-router";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
|
||||
export type FeedTab = "hot" | "new" | "journal" | "followed";
|
||||
export const VALID_TABS = new Set<string>(["hot", "new", "journal", "followed"]);
|
||||
export const VALID_TABS = new Set<string>([
|
||||
"hot",
|
||||
"new",
|
||||
"journal",
|
||||
"followed",
|
||||
]);
|
||||
|
||||
export function FeedTabBar() {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -8,7 +8,9 @@ interface PageShellProps {
|
||||
centerSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageShell({ children, centered = false, centerSlot }: PageShellProps) {
|
||||
export function PageShell(
|
||||
{ children, centered = false, centerSlot }: PageShellProps,
|
||||
) {
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<AppHeader centerSlot={centerSlot ?? <SearchBar />} />
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) inputRef.current?.focus();
|
||||
if (collapsible && expanded) inputRef.current?.focus();
|
||||
}, [expanded]);
|
||||
|
||||
function handleIconClick() {
|
||||
@@ -47,7 +47,9 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
|
||||
|
||||
return (
|
||||
<form
|
||||
className={`search-bar${collapsible ? " search-bar--collapsible" : ""}${expanded ? " search-bar--expanded" : ""}`}
|
||||
className={`search-bar${collapsible ? " search-bar--collapsible" : ""}${
|
||||
expanded ? " search-bar--expanded" : ""
|
||||
}`}
|
||||
onSubmit={handleSubmit}
|
||||
role="search"
|
||||
>
|
||||
|
||||
@@ -174,7 +174,9 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mention-textarea-wrap${dragOver ? " mention-textarea-wrap--dragover" : ""}`}
|
||||
className={`mention-textarea-wrap${
|
||||
dragOver ? " mention-textarea-wrap--dragover" : ""
|
||||
}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -10,7 +10,9 @@ export function UserMenu({ user }: { user: User }) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
|
||||
@@ -401,7 +401,9 @@ export function WSProvider(
|
||||
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify({ type: "vote_cast", dumpId } satisfies OutgoingWSMessage),
|
||||
JSON.stringify(
|
||||
{ type: "vote_cast", dumpId } satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface PublicUser {
|
||||
avatarMime?: string;
|
||||
description?: string;
|
||||
invitedByUsername?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
// User is the same shape as PublicUser in the frontend; they differ only
|
||||
@@ -464,6 +465,7 @@ export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
inviteToken: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface CreateUrlDumpRequest {
|
||||
@@ -505,4 +507,5 @@ export interface ReorderPlaylistRequest {
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
description?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ import { useLocation } from "react-router";
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
import { SearchBar } from "../components/SearchBar.tsx";
|
||||
import { PresenceRow } from "../components/PresenceRow.tsx";
|
||||
import { FeedTabBar, type FeedTab, VALID_TABS } from "../components/FeedTabBar.tsx";
|
||||
import {
|
||||
type FeedTab,
|
||||
FeedTabBar,
|
||||
VALID_TABS,
|
||||
} from "../components/FeedTabBar.tsx";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
|
||||
|
||||
@@ -111,7 +111,10 @@ export function Search() {
|
||||
...prev,
|
||||
dumps: {
|
||||
...prev.dumps,
|
||||
items: [...prev.dumps.items, ...data.dumps.items.map(deserializeDump)],
|
||||
items: [
|
||||
...prev.dumps.items,
|
||||
...data.dumps.items.map(deserializeDump),
|
||||
],
|
||||
hasMore: data.dumps.hasMore,
|
||||
page,
|
||||
loadingMore: false,
|
||||
@@ -131,7 +134,10 @@ export function Search() {
|
||||
}, [q, fetchSearch]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore) return;
|
||||
if (
|
||||
state.status !== "loaded" || !state.dumps.hasMore ||
|
||||
state.dumps.loadingMore
|
||||
) return;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return { ...prev, dumps: { ...prev.dumps, loadingMore: true } };
|
||||
@@ -141,7 +147,8 @@ export function Search() {
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
state.status === "loaded" && tab === "dumps" && state.dumps.hasMore && !state.dumps.loadingMore,
|
||||
state.status === "loaded" && tab === "dumps" && state.dumps.hasMore &&
|
||||
!state.dumps.loadingMore,
|
||||
);
|
||||
|
||||
function setTab(t: Tab) {
|
||||
@@ -154,10 +161,16 @@ export function Search() {
|
||||
|
||||
const dumpCount = state.status === "loaded" ? state.dumps.total : null;
|
||||
const userCount = state.status === "loaded" ? state.users.length : null;
|
||||
const playlistCount = state.status === "loaded" ? state.playlists.length : null;
|
||||
const playlistCount = state.status === "loaded"
|
||||
? state.playlists.length
|
||||
: null;
|
||||
|
||||
function tabLabel(t: Tab, count: number | null) {
|
||||
const label = t === "dumps" ? "Dumps" : t === "users" ? "Users" : "Playlists";
|
||||
const label = t === "dumps"
|
||||
? "Dumps"
|
||||
: t === "users"
|
||||
? "Users"
|
||||
: "Playlists";
|
||||
return count !== null ? `${label} (${count})` : label;
|
||||
}
|
||||
|
||||
@@ -174,7 +187,14 @@ export function Search() {
|
||||
className={`feed-sort-btn${tab === t ? " active" : ""}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{tabLabel(t, t === "dumps" ? dumpCount : t === "users" ? userCount : playlistCount)}
|
||||
{tabLabel(
|
||||
t,
|
||||
t === "dumps"
|
||||
? dumpCount
|
||||
: t === "users"
|
||||
? userCount
|
||||
: playlistCount,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -213,7 +233,9 @@ export function Search() {
|
||||
</ul>
|
||||
)}
|
||||
<div ref={sentinelRef} />
|
||||
{state.dumps.loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
{state.dumps.loadingMore && (
|
||||
<p className="feed-loading-more">Loading more…</p>
|
||||
)}
|
||||
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
|
||||
<p className="feed-end">You've reached the end.</p>
|
||||
)}
|
||||
@@ -227,10 +249,15 @@ export function Search() {
|
||||
<ul className="user-results">
|
||||
{state.users.map((u) => (
|
||||
<li key={u.id}>
|
||||
<Link to={`/users/${u.username}`} className="user-result-item">
|
||||
<Link
|
||||
to={`/users/${u.username}`}
|
||||
className="user-result-item"
|
||||
>
|
||||
<span className="user-result-name">@{u.username}</span>
|
||||
{u.description && (
|
||||
<span className="user-result-description">{u.description}</span>
|
||||
<span className="user-result-description">
|
||||
{u.description}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -233,6 +233,11 @@ export function UserPublicProfile() {
|
||||
const [descDraft, setDescDraft] = useState("");
|
||||
const [descSaving, setDescSaving] = useState(false);
|
||||
const [descError, setDescError] = useState<string | null>(null);
|
||||
|
||||
const [emailEditing, setEmailEditing] = useState(false);
|
||||
const [emailDraft, setEmailDraft] = useState("");
|
||||
const [emailSaving, setEmailSaving] = useState(false);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -507,6 +512,36 @@ export function UserPublicProfile() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailSave = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
setEmailSaving(true);
|
||||
setEmailError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ email: emailDraft.trim() } satisfies UpdateUserRequest,
|
||||
),
|
||||
});
|
||||
const body = parseAPIResponse<RawPublicUser>(await res.json());
|
||||
if (!body.success) {
|
||||
setEmailError(body.error.message);
|
||||
return;
|
||||
}
|
||||
const storedRaw = localStorage.getItem("authResponse");
|
||||
if (storedRaw) {
|
||||
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
|
||||
login({ ...prev, user: { ...prev.user, email: emailDraft.trim() } });
|
||||
}
|
||||
setEmailEditing(false);
|
||||
} catch {
|
||||
setEmailError("Failed to save");
|
||||
} finally {
|
||||
setEmailSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDescSave = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
setDescSaving(true);
|
||||
@@ -584,7 +619,7 @@ export function UserPublicProfile() {
|
||||
userId={profileUser.id}
|
||||
username={profileUser.username}
|
||||
hasAvatar={!!profileUser.avatarMime}
|
||||
size={72}
|
||||
size={128}
|
||||
version={profileUser.updatedAt?.getTime()}
|
||||
/>
|
||||
{isOwnProfile && (
|
||||
@@ -620,6 +655,66 @@ export function UserPublicProfile() {
|
||||
O.G.
|
||||
</p>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
emailEditing
|
||||
? (
|
||||
<form
|
||||
className="profile-email-editor"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleEmailSave();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
className="profile-email-input"
|
||||
value={emailDraft}
|
||||
onChange={(e) => setEmailDraft(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEmailEditing(false);
|
||||
}}
|
||||
disabled={emailSaving}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="profile-email-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="profile-email-btn profile-email-btn--save"
|
||||
disabled={emailSaving || !emailDraft.trim()}
|
||||
>
|
||||
{emailSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-email-btn profile-email-btn--cancel"
|
||||
onClick={() => setEmailEditing(false)}
|
||||
disabled={emailSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{emailError && (
|
||||
<ErrorCard title="Failed to save" message={emailError} />
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
: (
|
||||
<p
|
||||
className="profile-email-display"
|
||||
onClick={() => {
|
||||
setEmailDraft(me?.email ?? "");
|
||||
setEmailError(null);
|
||||
setEmailEditing(true);
|
||||
}}
|
||||
title="Edit email"
|
||||
>
|
||||
{me?.email ?? "Add email…"}
|
||||
<span className="profile-description-edit-btn" aria-hidden>
|
||||
✎
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{avatarError && (
|
||||
<ErrorCard title="Failed to update avatar" message={avatarError} />
|
||||
)}
|
||||
|
||||
@@ -54,13 +54,19 @@ export function UserRegister() {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const email = formData.get("email") as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/users/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ username, password, inviteToken: token } satisfies RegisterRequest,
|
||||
{
|
||||
username,
|
||||
password,
|
||||
inviteToken: token,
|
||||
email,
|
||||
} satisfies RegisterRequest,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -118,6 +124,13 @@ export function UserRegister() {
|
||||
disabled={formState.status === "submitting"}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
required
|
||||
disabled={formState.status === "submitting"}
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
|
||||
Reference in New Issue
Block a user