Compare commits
4 Commits
3c9910a723
...
f37d554399
| Author | SHA1 | Date | |
|---|---|---|---|
| f37d554399 | |||
| 2d5914c970 | |||
| 31a8f03ab2 | |||
| 90d7eab7d0 |
@@ -1056,6 +1056,159 @@ html, body {
|
|||||||
.mx-compose-wrapper { display: none; }
|
.mx-compose-wrapper { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Avatar image ── */
|
||||||
|
.mx-avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tweet sub-handle (@username) ── */
|
||||||
|
.mx-tweet-subhandle {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--mx-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Profile editor ── */
|
||||||
|
.mx-profile-editor {
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-profile-avatar-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-profile-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-profile-avatar-img {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
border: 2px solid var(--mx-border2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-profile-avatar-placeholder {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--mx-accent) 0%, var(--mx-accent2) 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-profile-avatar-edit-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mx-accent);
|
||||||
|
border: 2px solid var(--mx-bg);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.mx-profile-avatar-edit-btn:hover { background: var(--mx-accent2); }
|
||||||
|
.mx-profile-avatar-edit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.mx-profile-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mx-muted);
|
||||||
|
}
|
||||||
|
.mx-profile-stats strong { color: var(--mx-fg); }
|
||||||
|
|
||||||
|
.mx-profile-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-profile-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mx-fg2);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-profile-input {
|
||||||
|
background: var(--mx-surface);
|
||||||
|
border: 1px solid var(--mx-border2);
|
||||||
|
border-radius: var(--mx-radius-sm);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: var(--mx-fg);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.mx-profile-input:focus { border-color: var(--mx-accent); }
|
||||||
|
.mx-profile-input--readonly { color: var(--mx-muted); cursor: not-allowed; }
|
||||||
|
|
||||||
|
.mx-profile-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--mx-surface);
|
||||||
|
border: 1px solid var(--mx-border2);
|
||||||
|
border-radius: var(--mx-radius-sm);
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.mx-profile-input-wrap:focus-within { border-color: var(--mx-accent); }
|
||||||
|
|
||||||
|
.mx-profile-at {
|
||||||
|
color: var(--mx-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-profile-input--handle {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.mx-profile-input--handle:focus { border-color: transparent; }
|
||||||
|
|
||||||
|
.mx-profile-hint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--mx-muted);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Narrow phones (≤ 640 px): tighten spacing */
|
/* Narrow phones (≤ 640 px): tighten spacing */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.mx-feed { padding: 0.625rem; gap: 0.5rem; }
|
.mx-feed { padding: 0.625rem; gap: 0.5rem; }
|
||||||
|
|||||||
@@ -541,6 +541,83 @@ export async function validateReadUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type UpdateProfileInput = {
|
||||||
|
username?: string | null;
|
||||||
|
displayName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateProfileFields = UnifiedFieldSelection<usersResourceSchema>[];
|
||||||
|
|
||||||
|
export type InferUpdateProfileResult<
|
||||||
|
Fields extends UpdateProfileFields | undefined,
|
||||||
|
> = InferResult<usersResourceSchema, Fields>;
|
||||||
|
|
||||||
|
export type UpdateProfileResult<Fields extends UpdateProfileFields | undefined = undefined> = | { success: true; data: InferUpdateProfileResult<Fields>; }
|
||||||
|
| { success: false; errors: AshRpcError[]; }
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing User
|
||||||
|
*
|
||||||
|
* @ashActionType :update
|
||||||
|
*/
|
||||||
|
export async function updateProfile<Fields extends UpdateProfileFields | undefined = undefined>(
|
||||||
|
config: {
|
||||||
|
tenant?: string;
|
||||||
|
identity: UUID;
|
||||||
|
input?: UpdateProfileInput;
|
||||||
|
fields?: Fields;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
}
|
||||||
|
): Promise<UpdateProfileResult<Fields extends undefined ? [] : Fields>> {
|
||||||
|
const payload = {
|
||||||
|
action: "update_profile",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||||
|
identity: config.identity,
|
||||||
|
input: config.input,
|
||||||
|
...(config.fields !== undefined && { fields: config.fields })
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeActionRpcRequest<UpdateProfileResult<Fields extends undefined ? [] : Fields>>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate: Update an existing User
|
||||||
|
*
|
||||||
|
* @ashActionType :update
|
||||||
|
* @validation true
|
||||||
|
*/
|
||||||
|
export async function validateUpdateProfile(
|
||||||
|
config: {
|
||||||
|
tenant?: string;
|
||||||
|
identity: UUID | string;
|
||||||
|
input?: UpdateProfileInput;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
}
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const payload = {
|
||||||
|
action: "update_profile",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||||
|
identity: config.identity,
|
||||||
|
input: config.input
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeValidationRpcRequest<ValidationResult>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];
|
export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ export type followsAttributesOnlySchema = {
|
|||||||
// users Schema
|
// users Schema
|
||||||
export type usersResourceSchema = {
|
export type usersResourceSchema = {
|
||||||
__type: "Resource";
|
__type: "Resource";
|
||||||
__primitiveFields: "id" | "email" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId";
|
__primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId";
|
||||||
id: UUID;
|
id: UUID;
|
||||||
email: string;
|
email: string;
|
||||||
|
username: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
followerCount: number;
|
followerCount: number;
|
||||||
followingCount: number;
|
followingCount: number;
|
||||||
amIFollowing: boolean;
|
amIFollowing: boolean;
|
||||||
@@ -38,9 +41,12 @@ export type usersResourceSchema = {
|
|||||||
|
|
||||||
export type usersAttributesOnlySchema = {
|
export type usersAttributesOnlySchema = {
|
||||||
__type: "Resource";
|
__type: "Resource";
|
||||||
__primitiveFields: "id" | "email";
|
__primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl";
|
||||||
id: UUID;
|
id: UUID;
|
||||||
email: string;
|
email: string;
|
||||||
|
username: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +77,7 @@ export type mediaAttributesOnlySchema = {
|
|||||||
// tweets Schema
|
// tweets Schema
|
||||||
export type tweetsResourceSchema = {
|
export type tweetsResourceSchema = {
|
||||||
__type: "Resource";
|
__type: "Resource";
|
||||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail";
|
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail" | "userUsername" | "userDisplayName" | "userAvatarUrl";
|
||||||
id: UUID;
|
id: UUID;
|
||||||
content: string;
|
content: string;
|
||||||
likes: number;
|
likes: number;
|
||||||
@@ -82,6 +88,9 @@ export type tweetsResourceSchema = {
|
|||||||
commentCount: number;
|
commentCount: number;
|
||||||
likedByMe: boolean;
|
likedByMe: boolean;
|
||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
|
userUsername: string | null;
|
||||||
|
userDisplayName: string | null;
|
||||||
|
userAvatarUrl: string | null;
|
||||||
user: { __type: "Relationship"; __resource: usersResourceSchema; };
|
user: { __type: "Relationship"; __resource: usersResourceSchema; };
|
||||||
parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
|
parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
|
||||||
comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; };
|
comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; };
|
||||||
@@ -134,6 +143,27 @@ export type usersFilterInput = {
|
|||||||
in?: Array<string>;
|
in?: Array<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
username?: {
|
||||||
|
eq?: string;
|
||||||
|
notEq?: string;
|
||||||
|
in?: Array<string>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
displayName?: {
|
||||||
|
eq?: string;
|
||||||
|
notEq?: string;
|
||||||
|
in?: Array<string>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
avatarUrl?: {
|
||||||
|
eq?: string;
|
||||||
|
notEq?: string;
|
||||||
|
in?: Array<string>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
followerCount?: {
|
followerCount?: {
|
||||||
eq?: number;
|
eq?: number;
|
||||||
notEq?: number;
|
notEq?: number;
|
||||||
@@ -270,6 +300,27 @@ export type tweetsFilterInput = {
|
|||||||
isNil?: boolean;
|
isNil?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
userUsername?: {
|
||||||
|
eq?: string;
|
||||||
|
notEq?: string;
|
||||||
|
in?: Array<string>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
userDisplayName?: {
|
||||||
|
eq?: string;
|
||||||
|
notEq?: string;
|
||||||
|
in?: Array<string>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
userAvatarUrl?: {
|
||||||
|
eq?: string;
|
||||||
|
notEq?: string;
|
||||||
|
in?: Array<string>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
commentCount?: {
|
commentCount?: {
|
||||||
eq?: number;
|
eq?: number;
|
||||||
notEq?: number;
|
notEq?: number;
|
||||||
@@ -301,26 +352,26 @@ export type tweetsFilterInput = {
|
|||||||
export const followsFilterFields = ["id"] as const;
|
export const followsFilterFields = ["id"] as const;
|
||||||
export type followsFilterField = (typeof followsFilterFields)[number];
|
export type followsFilterField = (typeof followsFilterFields)[number];
|
||||||
|
|
||||||
export const usersFilterFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
|
export const usersFilterFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
|
||||||
export type usersFilterField = (typeof usersFilterFields)[number];
|
export type usersFilterField = (typeof usersFilterFields)[number];
|
||||||
|
|
||||||
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
|
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
|
||||||
export type mediaFilterField = (typeof mediaFilterFields)[number];
|
export type mediaFilterField = (typeof mediaFilterFields)[number];
|
||||||
|
|
||||||
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe", "user", "parentTweet", "comments", "media"] as const;
|
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "commentCount", "likedByMe", "user", "parentTweet", "comments", "media"] as const;
|
||||||
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
|
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
|
||||||
|
|
||||||
|
|
||||||
export const followsSortFields = ["id"] as const;
|
export const followsSortFields = ["id"] as const;
|
||||||
export type followsSortField = (typeof followsSortFields)[number];
|
export type followsSortField = (typeof followsSortFields)[number];
|
||||||
|
|
||||||
export const usersSortFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
|
export const usersSortFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
|
||||||
export type usersSortField = (typeof usersSortFields)[number];
|
export type usersSortField = (typeof usersSortFields)[number];
|
||||||
|
|
||||||
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
|
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
|
||||||
export type mediaSortField = (typeof mediaSortFields)[number];
|
export type mediaSortField = (typeof mediaSortFields)[number];
|
||||||
|
|
||||||
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe"] as const;
|
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "commentCount", "likedByMe"] as const;
|
||||||
export type tweetsSortField = (typeof tweetsSortFields)[number];
|
export type tweetsSortField = (typeof tweetsSortFields)[number];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ import {
|
|||||||
likeTweet,
|
likeTweet,
|
||||||
unlikeTweet,
|
unlikeTweet,
|
||||||
updateTweet,
|
updateTweet,
|
||||||
|
updateProfile,
|
||||||
readUser,
|
readUser,
|
||||||
followUser,
|
followUser,
|
||||||
unfollowUser,
|
unfollowUser,
|
||||||
buildCSRFHeaders,
|
buildCSRFHeaders,
|
||||||
} from "./ash_rpc";
|
} from "./ash_rpc";
|
||||||
import { uploadFile } from "./upload";
|
import { uploadFile, uploadAvatar } from "./upload";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { staleTime: 10_000 } },
|
defaultOptions: { queries: { staleTime: 10_000 } },
|
||||||
@@ -33,6 +34,9 @@ const queryClient = new QueryClient({
|
|||||||
type User = {
|
type User = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
username?: string | null;
|
||||||
|
displayName?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
followerCount?: number;
|
followerCount?: number;
|
||||||
followingCount?: number;
|
followingCount?: number;
|
||||||
amIFollowing?: boolean;
|
amIFollowing?: boolean;
|
||||||
@@ -50,12 +54,21 @@ type Tweet = {
|
|||||||
state: string;
|
state: string;
|
||||||
media?: MediaItem[];
|
media?: MediaItem[];
|
||||||
userEmail?: string | null;
|
userEmail?: string | null;
|
||||||
|
userUsername?: string | null;
|
||||||
|
userDisplayName?: string | null;
|
||||||
|
userAvatarUrl?: string | null;
|
||||||
insertedAt?: string | null;
|
insertedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Auth context ───────────────────────────────────────────────────────────────
|
// ── Auth context ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const AuthCtx = createContext({ email: "", userId: "" });
|
const AuthCtx = createContext({
|
||||||
|
email: "",
|
||||||
|
userId: "",
|
||||||
|
username: "",
|
||||||
|
displayName: "",
|
||||||
|
avatarUrl: "",
|
||||||
|
});
|
||||||
|
|
||||||
// ── Responsive helper ─────────────────────────────────────────────────────────
|
// ── Responsive helper ─────────────────────────────────────────────────────────
|
||||||
// Returns true when the viewport is wider than 960 px (desktop layout).
|
// Returns true when the viewport is wider than 960 px (desktop layout).
|
||||||
@@ -102,6 +115,54 @@ function getAssetHost(): string {
|
|||||||
return appEl?.dataset.assetHost ?? "http://localhost:9000";
|
return appEl?.dataset.assetHost ?? "http://localhost:9000";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Display-name helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function userDisplayLabel(u: {
|
||||||
|
displayName?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}): string {
|
||||||
|
return u.displayName || u.username || u.email || "@mixer";
|
||||||
|
}
|
||||||
|
|
||||||
|
function userHandle(u: { username?: string | null; email?: string | null }): string {
|
||||||
|
return u.username ? `@${u.username}` : u.email ?? "@mixer";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Avatar ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
avatarUrl,
|
||||||
|
name,
|
||||||
|
size = "md",
|
||||||
|
}: {
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}) {
|
||||||
|
const assetHost = getAssetHost();
|
||||||
|
const initial = ((name ?? "")[0] || "M").toUpperCase();
|
||||||
|
const cls = size === "sm"
|
||||||
|
? "mx-tweet-avatar mx-tweet-avatar--sm"
|
||||||
|
: size === "lg"
|
||||||
|
? "mx-tweet-avatar mx-tweet-avatar--lg"
|
||||||
|
: "mx-tweet-avatar";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cls}>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={`${assetHost}/${avatarUrl}`}
|
||||||
|
alt={name ?? "avatar"}
|
||||||
|
className="mx-avatar-img"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{initial}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Context menu ──────────────────────────────────────────────────────────────
|
// ── Context menu ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ContextMenuItem =
|
type ContextMenuItem =
|
||||||
@@ -203,6 +264,7 @@ function CharCount({ current, max }: { current: number; max: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
|
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
@@ -304,9 +366,7 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-compose">
|
<div className="mx-compose">
|
||||||
<div className="mx-compose-avatar">
|
<Avatar avatarUrl={avatarUrl} name={displayName || username || email} />
|
||||||
<span>M</span>
|
|
||||||
</div>
|
|
||||||
<div className="mx-compose-body">
|
<div className="mx-compose-body">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -546,12 +606,13 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
||||||
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||||
>
|
>
|
||||||
<div className="mx-tweet-avatar">
|
<Avatar avatarUrl={tweet.userAvatarUrl} name={tweet.userDisplayName || tweet.userUsername || tweet.userEmail} />
|
||||||
<span>M</span>
|
|
||||||
</div>
|
|
||||||
<div className="mx-tweet-body">
|
<div className="mx-tweet-body">
|
||||||
<div className="mx-tweet-header">
|
<div className="mx-tweet-header">
|
||||||
<span className="mx-tweet-handle">{tweet.userEmail ?? "@mixer"}</span>
|
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}</span>
|
||||||
|
{tweet.userUsername && (
|
||||||
|
<span className="mx-tweet-subhandle">@{tweet.userUsername}</span>
|
||||||
|
)}
|
||||||
<span className="mx-tweet-dot">·</span>
|
<span className="mx-tweet-dot">·</span>
|
||||||
<span className="mx-tweet-time" title={tweet.insertedAt ? new Date(tweet.insertedAt).toLocaleString() : undefined}>{timeAgo(tweet.insertedAt)}</span>
|
<span className="mx-tweet-time" title={tweet.insertedAt ? new Date(tweet.insertedAt).toLocaleString() : undefined}>{timeAgo(tweet.insertedAt)}</span>
|
||||||
{canModify && (
|
{canModify && (
|
||||||
@@ -754,11 +815,11 @@ function ComposeComment({ parentTweetId, onSuccess }: { parentTweetId: string; o
|
|||||||
el.style.height = `${el.scrollHeight}px`;
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
|
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-compose mx-compose--comment">
|
<div className="mx-compose mx-compose--comment">
|
||||||
<div className="mx-compose-avatar mx-compose-avatar--sm">
|
<Avatar avatarUrl={avatarUrl} name={displayName || username || email} size="sm" />
|
||||||
<span>M</span>
|
|
||||||
</div>
|
|
||||||
<div className="mx-compose-body">
|
<div className="mx-compose-body">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -803,7 +864,7 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
|
|||||||
queryKey: ["tweet", tweetId],
|
queryKey: ["tweet", tweetId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await readTweet({
|
const res = await readTweet({
|
||||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
filter: { id: { eq: tweetId } },
|
filter: { id: { eq: tweetId } },
|
||||||
headers: buildCSRFHeaders(),
|
headers: buildCSRFHeaders(),
|
||||||
});
|
});
|
||||||
@@ -825,7 +886,7 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
|
|||||||
queryKey: ["comments", tweetId],
|
queryKey: ["comments", tweetId],
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const res = await readTweet({
|
const res = await readTweet({
|
||||||
fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
filter: { parentTweetId: { eq: tweetId } },
|
filter: { parentTweetId: { eq: tweetId } },
|
||||||
sort: "insertedAt",
|
sort: "insertedAt",
|
||||||
page: { limit: COMMENTS_PAGE_SIZE, offset: pageParam },
|
page: { limit: COMMENTS_PAGE_SIZE, offset: pageParam },
|
||||||
@@ -950,10 +1011,13 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
|
|||||||
|
|
||||||
<div className="mx-detail-body">
|
<div className="mx-detail-body">
|
||||||
<div className="mx-detail-author">
|
<div className="mx-detail-author">
|
||||||
<div className="mx-tweet-avatar">
|
<Avatar avatarUrl={tweet.userAvatarUrl} name={tweet.userDisplayName || tweet.userUsername || tweet.userEmail} />
|
||||||
<span>M</span>
|
<div>
|
||||||
|
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}</span>
|
||||||
|
{tweet.userUsername && (
|
||||||
|
<div style={{ fontSize: "0.8rem", color: "var(--mx-muted)" }}>@{tweet.userUsername}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="mx-tweet-handle">{tweet.userEmail ?? "@mixer"}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
@@ -1091,12 +1155,13 @@ function CommentCard({ comment, parentTweetOwnerId }: { comment: Tweet; parentTw
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="mx-tweet mx-comment">
|
<article className="mx-tweet mx-comment">
|
||||||
<div className="mx-tweet-avatar mx-tweet-avatar--sm">
|
<Avatar avatarUrl={comment.userAvatarUrl} name={comment.userDisplayName || comment.userUsername || comment.userEmail} size="sm" />
|
||||||
<span>M</span>
|
|
||||||
</div>
|
|
||||||
<div className="mx-tweet-body">
|
<div className="mx-tweet-body">
|
||||||
<div className="mx-tweet-header">
|
<div className="mx-tweet-header">
|
||||||
<span className="mx-tweet-handle">{comment.userEmail ?? "@mixer"}</span>
|
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: comment.userDisplayName, username: comment.userUsername, email: comment.userEmail })}</span>
|
||||||
|
{comment.userUsername && (
|
||||||
|
<span className="mx-tweet-subhandle">@{comment.userUsername}</span>
|
||||||
|
)}
|
||||||
<span className="mx-tweet-dot">·</span>
|
<span className="mx-tweet-dot">·</span>
|
||||||
<span className="mx-tweet-time" title={comment.insertedAt ? new Date(comment.insertedAt).toLocaleString() : undefined}>{timeAgo(comment.insertedAt)}</span>
|
<span className="mx-tweet-time" title={comment.insertedAt ? new Date(comment.insertedAt).toLocaleString() : undefined}>{timeAgo(comment.insertedAt)}</span>
|
||||||
{canModify && (
|
{canModify && (
|
||||||
@@ -1169,7 +1234,7 @@ function FollowingFeed() {
|
|||||||
queryKey: ["following_tweets"],
|
queryKey: ["following_tweets"],
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const res = await readFollowingFeed({
|
const res = await readFollowingFeed({
|
||||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
sort: "-insertedAt",
|
sort: "-insertedAt",
|
||||||
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||||
filter: { parentTweetId: { isNil: true } },
|
filter: { parentTweetId: { isNil: true } },
|
||||||
@@ -1263,7 +1328,7 @@ function Feed() {
|
|||||||
queryKey: ["tweets"],
|
queryKey: ["tweets"],
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const res = await readTweet({
|
const res = await readTweet({
|
||||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
sort: "-insertedAt",
|
sort: "-insertedAt",
|
||||||
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||||
filter: { parentTweetId: { isNil: true } },
|
filter: { parentTweetId: { isNil: true } },
|
||||||
@@ -1428,12 +1493,13 @@ function UserCard({ user }: { user: User }) {
|
|||||||
onClick={() => { window.location.href = `/users/${user.id}`; }}
|
onClick={() => { window.location.href = `/users/${user.id}`; }}
|
||||||
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||||
>
|
>
|
||||||
<div className="mx-tweet-avatar">
|
<Avatar avatarUrl={user.avatarUrl} name={user.displayName || user.username || user.email} />
|
||||||
<span>M</span>
|
|
||||||
</div>
|
|
||||||
<div className="mx-tweet-body">
|
<div className="mx-tweet-body">
|
||||||
<div className="mx-tweet-header">
|
<div className="mx-tweet-header">
|
||||||
<span className="mx-tweet-handle">{user.email}</span>
|
<span className="mx-tweet-handle">{userDisplayLabel(user)}</span>
|
||||||
|
{user.username && (
|
||||||
|
<span className="mx-tweet-subhandle">@{user.username}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(user.followerCount !== undefined || user.followingCount !== undefined) && (
|
{(user.followerCount !== undefined || user.followingCount !== undefined) && (
|
||||||
<div className="mx-tweet-meta" style={{ fontSize: "0.8rem", color: "var(--mx-muted)", marginTop: "4px" }}>
|
<div className="mx-tweet-meta" style={{ fontSize: "0.8rem", color: "var(--mx-muted)", marginTop: "4px" }}>
|
||||||
@@ -1459,7 +1525,7 @@ function UserList() {
|
|||||||
queryKey: ["users"],
|
queryKey: ["users"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await readUser({
|
const res = await readUser({
|
||||||
fields: ["id", "email", "followerCount", "followingCount", "amIFollowing"],
|
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||||
headers: buildCSRFHeaders(),
|
headers: buildCSRFHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.success) throw new Error("Failed to load users");
|
if (!res.success) throw new Error("Failed to load users");
|
||||||
@@ -1499,7 +1565,7 @@ function UserDetail({ userId, isStandalone = false }: { userId: string; isStanda
|
|||||||
queryKey: ["user", userId],
|
queryKey: ["user", userId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await readUser({
|
const res = await readUser({
|
||||||
fields: ["id", "email", "followerCount", "followingCount", "amIFollowing"],
|
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||||
filter: { id: { eq: userId } },
|
filter: { id: { eq: userId } },
|
||||||
headers: buildCSRFHeaders(),
|
headers: buildCSRFHeaders(),
|
||||||
});
|
});
|
||||||
@@ -1530,27 +1596,212 @@ function UserDetail({ userId, isStandalone = false }: { userId: string; isStanda
|
|||||||
)}
|
)}
|
||||||
<div className="mx-detail-body">
|
<div className="mx-detail-body">
|
||||||
<div className="mx-detail-author">
|
<div className="mx-detail-author">
|
||||||
<div className="mx-tweet-avatar mx-tweet-avatar--lg">
|
<Avatar avatarUrl={user.avatarUrl} name={user.displayName || user.username || user.email} size="lg" />
|
||||||
<span>{user.email?.[0]?.toUpperCase() ?? "M"}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap" }}>
|
||||||
<span className="mx-tweet-handle">{user.email}</span>
|
<div>
|
||||||
|
<div className="mx-tweet-handle" style={{ fontSize: "1.1rem" }}>{userDisplayLabel(user)}</div>
|
||||||
|
{user.username && (
|
||||||
|
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)" }}>@{user.username}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{canFollow && (
|
{canFollow && (
|
||||||
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
|
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
|
||||||
)}
|
)}
|
||||||
{isOwnProfile && isStandalone && (
|
|
||||||
<a href="/sign-out" className="mx-btn-cancel" style={{ textDecoration: "none", fontSize: "0.8rem" }}>
|
|
||||||
Sign out
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "6px", display: "flex", gap: "16px" }}>
|
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "8px", display: "flex", gap: "16px" }}>
|
||||||
|
<span><strong style={{ color: "var(--mx-fg)" }}>{user.followerCount ?? 0}</strong> followers</span>
|
||||||
|
<span><strong style={{ color: "var(--mx-fg)" }}>{user.followingCount ?? 0}</strong> following</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileEditor({ userId }: { userId: string }) {
|
||||||
|
const assetHost = getAssetHost();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: user, isLoading } = useQuery({
|
||||||
|
queryKey: ["user", userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await readUser({
|
||||||
|
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||||
|
filter: { id: { eq: userId } },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load user");
|
||||||
|
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||||
|
return (results[0] as User) ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||||
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
|
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null);
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Sync form fields when user data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setUsername(user.username ?? "");
|
||||||
|
setDisplayName(user.displayName ?? "");
|
||||||
|
}
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await updateProfile({
|
||||||
|
identity: userId,
|
||||||
|
input: {
|
||||||
|
username: username.trim() || null,
|
||||||
|
displayName: displayName.trim() || null,
|
||||||
|
},
|
||||||
|
fields: ["id", "username", "displayName", "avatarUrl"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Save failed");
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["user", userId] });
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setSaveError(null);
|
||||||
|
setTimeout(() => setSaveSuccess(false), 3000);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setSaveError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = "";
|
||||||
|
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
|
||||||
|
setPreviewAvatarUrl(URL.createObjectURL(file));
|
||||||
|
setAvatarError(null);
|
||||||
|
setAvatarUploading(true);
|
||||||
|
const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string;
|
||||||
|
const result = await uploadAvatar(file, csrfToken);
|
||||||
|
setAvatarUploading(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setAvatarError(result.error);
|
||||||
|
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
|
||||||
|
setPreviewAvatarUrl(null);
|
||||||
|
} else {
|
||||||
|
qc.invalidateQueries({ queryKey: ["user", userId] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !user) return <Spinner />;
|
||||||
|
|
||||||
|
const currentAvatarUrl = previewAvatarUrl
|
||||||
|
? previewAvatarUrl
|
||||||
|
: user.avatarUrl
|
||||||
|
? `${assetHost}/${user.avatarUrl}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-profile-editor">
|
||||||
|
{/* Avatar section */}
|
||||||
|
<div className="mx-profile-avatar-section">
|
||||||
|
<div className="mx-profile-avatar-wrap">
|
||||||
|
{currentAvatarUrl ? (
|
||||||
|
<img src={currentAvatarUrl} alt="Your avatar" className="mx-profile-avatar-img" />
|
||||||
|
) : (
|
||||||
|
<div className="mx-profile-avatar-placeholder">
|
||||||
|
<span>{(user.displayName || user.username || user.email || "M")[0].toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="mx-profile-avatar-edit-btn"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
disabled={avatarUploading}
|
||||||
|
title="Change avatar"
|
||||||
|
>
|
||||||
|
{avatarUploading ? (
|
||||||
|
<div className="mx-spinner" style={{ width: "14px", height: "14px", borderWidth: "2px" }} />
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.31-2.31a1 1 0 0 0-1.41 0l-1.79 1.79 3.75 3.75 1.76-1.82z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{avatarError && <p className="mx-compose-error" style={{ marginTop: "0.5rem" }}>{avatarError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="mx-profile-stats">
|
||||||
<span><strong>{user.followerCount ?? 0}</strong> followers</span>
|
<span><strong>{user.followerCount ?? 0}</strong> followers</span>
|
||||||
<span><strong>{user.followingCount ?? 0}</strong> following</span>
|
<span><strong>{user.followingCount ?? 0}</strong> following</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email (read-only) */}
|
||||||
|
<div className="mx-profile-field">
|
||||||
|
<label className="mx-profile-label">Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mx-profile-input mx-profile-input--readonly"
|
||||||
|
value={String(user.email)}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Display name */}
|
||||||
|
<div className="mx-profile-field">
|
||||||
|
<label className="mx-profile-label">Display name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mx-profile-input"
|
||||||
|
placeholder="Your display name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Username */}
|
||||||
|
<div className="mx-profile-field">
|
||||||
|
<label className="mx-profile-label">Username</label>
|
||||||
|
<div className="mx-profile-input-wrap">
|
||||||
|
<span className="mx-profile-at">@</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mx-profile-input mx-profile-input--handle"
|
||||||
|
placeholder="your_handle"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))}
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mx-profile-hint">3–30 characters. Letters, numbers, underscores only.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError && <p className="mx-compose-error">{saveError}</p>}
|
||||||
|
{saveSuccess && <p style={{ fontSize: "0.8rem", color: "var(--mx-green)", marginBottom: "0.5rem" }}>✓ Saved!</p>}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
className="mx-btn-post"
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? "Saving…" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
<a href="/sign-out" className="mx-btn-cancel" style={{ textDecoration: "none" }}>Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1572,7 +1823,7 @@ function MyProfile() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <UserDetail userId={userId} isStandalone />;
|
return <ProfileEditor userId={userId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mobile bottom nav ─────────────────────────────────────────────────────────
|
// ── Mobile bottom nav ─────────────────────────────────────────────────────────
|
||||||
@@ -1691,6 +1942,9 @@ function App() {
|
|||||||
const appEl = document.getElementById("app")!;
|
const appEl = document.getElementById("app")!;
|
||||||
const email = appEl.dataset.currentUserEmail ?? "";
|
const email = appEl.dataset.currentUserEmail ?? "";
|
||||||
const userId = appEl.dataset.currentUserId ?? "";
|
const userId = appEl.dataset.currentUserId ?? "";
|
||||||
|
const username = appEl.dataset.currentUserUsername ?? "";
|
||||||
|
const displayName = appEl.dataset.currentUserDisplayName ?? "";
|
||||||
|
const avatarUrl = appEl.dataset.currentUserAvatarUrl ?? "";
|
||||||
const tweetId = appEl.dataset.tweetId || null;
|
const tweetId = appEl.dataset.tweetId || null;
|
||||||
const page = appEl.dataset.page ?? "feed";
|
const page = appEl.dataset.page ?? "feed";
|
||||||
const profileUserId = appEl.dataset.userId || null;
|
const profileUserId = appEl.dataset.userId || null;
|
||||||
@@ -1779,7 +2033,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthCtx.Provider value={{ email, userId }}>
|
<AuthCtx.Provider value={{ email, userId, username, displayName, avatarUrl }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<div className="mx-root">
|
<div className="mx-root">
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
@@ -1817,7 +2071,12 @@ function App() {
|
|||||||
<div className="mx-sidebar-footer">
|
<div className="mx-sidebar-footer">
|
||||||
{email ? (
|
{email ? (
|
||||||
<>
|
<>
|
||||||
<span className="mx-version" style={{ color: "var(--mx-fg2)" }}>{email}</span>
|
<span className="mx-version" style={{ color: "var(--mx-fg2)" }}>
|
||||||
|
{displayName || username || email}
|
||||||
|
</span>
|
||||||
|
{username && (
|
||||||
|
<span className="mx-version">@{username}</span>
|
||||||
|
)}
|
||||||
<a className="mx-auth-link" href="/sign-out">Sign out</a>
|
<a className="mx-auth-link" href="/sign-out">Sign out</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -9,6 +9,29 @@ export interface UploadError {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AvatarUploadResult {
|
||||||
|
success: true;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAvatar(
|
||||||
|
file: File,
|
||||||
|
csrfToken: string
|
||||||
|
): Promise<AvatarUploadResult | UploadError> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const res = await fetch("/upload/avatar", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-CSRF-Token": csrfToken },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || !json.success) {
|
||||||
|
return { error: json.error ?? "Upload failed" };
|
||||||
|
}
|
||||||
|
return json as AvatarUploadResult;
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadFile(
|
export async function uploadFile(
|
||||||
file: File,
|
file: File,
|
||||||
csrfToken: string
|
csrfToken: string
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ defmodule Mixer.Accounts do
|
|||||||
typescript_rpc do
|
typescript_rpc do
|
||||||
resource Mixer.Accounts.User do
|
resource Mixer.Accounts.User do
|
||||||
rpc_action :read_user, :read
|
rpc_action :read_user, :read
|
||||||
|
rpc_action :update_profile, :update_profile
|
||||||
end
|
end
|
||||||
|
|
||||||
resource Mixer.Accounts.Follow do
|
resource Mixer.Accounts.Follow do
|
||||||
|
|||||||
33
lib/mixer/accounts/avatar_uploader.ex
Normal file
33
lib/mixer/accounts/avatar_uploader.ex
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
defmodule Mixer.Accounts.AvatarUploader do
|
||||||
|
use Waffle.Definition
|
||||||
|
|
||||||
|
@versions [:original, :thumb]
|
||||||
|
@extensions ~w(.jpg .jpeg .png .gif .webp)
|
||||||
|
|
||||||
|
def validate({file, _scope}) do
|
||||||
|
ext = file.file_name |> Path.extname() |> String.downcase()
|
||||||
|
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Resize to a 256×256 square (centre-crop) and convert to WebP for efficiency
|
||||||
|
def transform(:thumb, _) do
|
||||||
|
{:convert, "-strip -thumbnail 256x256^ -gravity center -extent 256x256 -format webp", :webp}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Store both versions under avatars/:user_id/
|
||||||
|
def storage_dir(_version, {_file, scope}), do: "avatars/#{scope.user_id}"
|
||||||
|
|
||||||
|
def filename(:original, {file, _scope}) do
|
||||||
|
Path.basename(file.file_name, Path.extname(file.file_name))
|
||||||
|
end
|
||||||
|
|
||||||
|
def filename(:thumb, _), do: "thumb"
|
||||||
|
|
||||||
|
def s3_object_headers(:thumb, _), do: [content_type: "image/webp"]
|
||||||
|
|
||||||
|
def s3_object_headers(_version, {file, _scope}) do
|
||||||
|
[content_type: MIME.from_path(file.file_name)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def acl(_version, _), do: :public_read
|
||||||
|
end
|
||||||
@@ -177,9 +177,21 @@ defmodule Mixer.Accounts.User do
|
|||||||
sensitive? true
|
sensitive? true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
argument :username, :string do
|
||||||
|
description "The desired username for the user (letters, numbers, underscores)."
|
||||||
|
allow_nil? false
|
||||||
|
|
||||||
|
constraints match: ~r/^[a-zA-Z0-9_]+$/,
|
||||||
|
min_length: 3,
|
||||||
|
max_length: 30
|
||||||
|
end
|
||||||
|
|
||||||
# Sets the email from the argument
|
# Sets the email from the argument
|
||||||
change set_attribute(:email, arg(:email))
|
change set_attribute(:email, arg(:email))
|
||||||
|
|
||||||
|
# Sets the username from the argument
|
||||||
|
change set_attribute(:username, arg(:username))
|
||||||
|
|
||||||
# Hashes the provided password
|
# Hashes the provided password
|
||||||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||||
|
|
||||||
@@ -211,6 +223,18 @@ defmodule Mixer.Accounts.User do
|
|||||||
get_by :email
|
get_by :email
|
||||||
end
|
end
|
||||||
|
|
||||||
|
update :update_profile do
|
||||||
|
description "Update the user's public profile (username, display name)."
|
||||||
|
accept [:username, :display_name]
|
||||||
|
require_atomic? false
|
||||||
|
end
|
||||||
|
|
||||||
|
update :update_avatar do
|
||||||
|
description "Store the S3 key of the user's processed avatar thumbnail."
|
||||||
|
accept [:avatar_url]
|
||||||
|
require_atomic? false
|
||||||
|
end
|
||||||
|
|
||||||
update :reset_password_with_token do
|
update :reset_password_with_token do
|
||||||
argument :reset_token, :string do
|
argument :reset_token, :string do
|
||||||
allow_nil? false
|
allow_nil? false
|
||||||
@@ -256,6 +280,15 @@ defmodule Mixer.Accounts.User do
|
|||||||
allow_nil? true
|
allow_nil? true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
argument :username, :string do
|
||||||
|
description "Username chosen during first-time magic link registration."
|
||||||
|
allow_nil? true
|
||||||
|
|
||||||
|
constraints match: ~r/^[a-zA-Z0-9_]+$/,
|
||||||
|
min_length: 3,
|
||||||
|
max_length: 30
|
||||||
|
end
|
||||||
|
|
||||||
upsert? true
|
upsert? true
|
||||||
upsert_identity :unique_email
|
upsert_identity :unique_email
|
||||||
upsert_fields [:email]
|
upsert_fields [:email]
|
||||||
@@ -266,6 +299,37 @@ defmodule Mixer.Accounts.User do
|
|||||||
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
|
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
|
||||||
strategy_name: :remember_me}
|
strategy_name: :remember_me}
|
||||||
|
|
||||||
|
# Set username on new users (or existing users who haven't set one yet)
|
||||||
|
change fn changeset, _ctx ->
|
||||||
|
case Ash.Changeset.get_argument(changeset, :username) do
|
||||||
|
nil ->
|
||||||
|
changeset
|
||||||
|
|
||||||
|
username ->
|
||||||
|
# Set the attribute directly so the unique_username identity's
|
||||||
|
# eager_check_with fires during Form.validate, surfacing "already
|
||||||
|
# taken" errors in the UI before the action is submitted.
|
||||||
|
changeset = Ash.Changeset.change_attribute(changeset, :username, username)
|
||||||
|
|
||||||
|
# Also update via after_action to handle existing users who have no
|
||||||
|
# username yet: for upserts, only upsert_fields are applied to the
|
||||||
|
# conflicting row, so change_attribute above won't touch them.
|
||||||
|
Ash.Changeset.after_action(changeset, fn _cs, user ->
|
||||||
|
if is_nil(user.username) do
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_profile, %{username: username},authorize?: false)
|
||||||
|
|> Ash.update()
|
||||||
|
|> case do
|
||||||
|
{:ok, updated} -> {:ok, updated}
|
||||||
|
{:error, error} -> {:error, error}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:ok, user}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
metadata :token, :string do
|
metadata :token, :string do
|
||||||
allow_nil? false
|
allow_nil? false
|
||||||
end
|
end
|
||||||
@@ -293,6 +357,14 @@ defmodule Mixer.Accounts.User do
|
|||||||
policy action_type(:read) do
|
policy action_type(:read) do
|
||||||
authorize_if always()
|
authorize_if always()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
policy action(:update_profile) do
|
||||||
|
authorize_if expr(id == ^actor(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
policy action(:update_avatar) do
|
||||||
|
authorize_if expr(id == ^actor(:id))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes do
|
attributes do
|
||||||
@@ -308,6 +380,23 @@ defmodule Mixer.Accounts.User do
|
|||||||
end
|
end
|
||||||
|
|
||||||
attribute :confirmed_at, :utc_datetime_usec
|
attribute :confirmed_at, :utc_datetime_usec
|
||||||
|
|
||||||
|
attribute :username, :string do
|
||||||
|
public? true
|
||||||
|
|
||||||
|
constraints match: ~r/^[a-zA-Z0-9_]+$/,
|
||||||
|
min_length: 3,
|
||||||
|
max_length: 30
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :display_name, :string do
|
||||||
|
public? true
|
||||||
|
constraints max_length: 50
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :avatar_url, :string do
|
||||||
|
public? true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
@@ -350,5 +439,10 @@ defmodule Mixer.Accounts.User do
|
|||||||
|
|
||||||
identities do
|
identities do
|
||||||
identity :unique_email, [:email]
|
identity :unique_email, [:email]
|
||||||
|
identity :unique_username, [:username] do
|
||||||
|
eager_check_with Mixer.Accounts
|
||||||
|
message "is already taken"
|
||||||
|
nils_distinct? true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -254,6 +254,18 @@ defmodule Mixer.Posts.Tweet do
|
|||||||
calculate :user_email, :string, expr(user.email) do
|
calculate :user_email, :string, expr(user.email) do
|
||||||
public? true
|
public? true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
calculate :user_username, :string, expr(user.username) do
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
calculate :user_display_name, :string, expr(user.display_name) do
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
calculate :user_avatar_url, :string, expr(user.avatar_url) do
|
||||||
|
public? true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
aggregates do
|
aggregates do
|
||||||
|
|||||||
@@ -15,4 +15,9 @@ defmodule MixerWeb.AuthOverrides do
|
|||||||
set :text, "⬡ Mixer"
|
set :text, "⬡ Mixer"
|
||||||
set :text_class, "text-3xl font-bold tracking-tight"
|
set :text_class, "text-3xl font-bold tracking-tight"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Inject the username field into the password registration form
|
||||||
|
override AshAuthentication.Phoenix.Components.Password do
|
||||||
|
set :register_extra_component, &MixerWeb.AuthComponents.username_field/1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
55
lib/mixer_web/components/auth_components.ex
Normal file
55
lib/mixer_web/components/auth_components.ex
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
defmodule MixerWeb.AuthComponents do
|
||||||
|
@moduledoc """
|
||||||
|
Extra components injected into AshAuthentication.Phoenix forms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a username input field inside the password registration form.
|
||||||
|
|
||||||
|
Receives `form` (an `AshPhoenix.Form`) as an assign via the
|
||||||
|
`register_extra_component` override.
|
||||||
|
"""
|
||||||
|
def username_field(assigns) do
|
||||||
|
field = assigns.form[:username]
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:field_id, field.id)
|
||||||
|
|> assign(:field_name, field.name)
|
||||||
|
|> assign(:field_value, field.value || "")
|
||||||
|
|> assign(:field_errors, field.errors)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="mt-2 mb-2">
|
||||||
|
<label for={@field_id} class="block text-sm font-medium text-base-content mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div class="flex">
|
||||||
|
<span class="flex items-center justify-center px-4 bg-base-200 border border-base-300 border-r-0 rounded-l-lg text-base-content/50 select-none">@</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={@field_id}
|
||||||
|
name={@field_name}
|
||||||
|
value={@field_value}
|
||||||
|
class={"input w-full rounded-l-none #{if @field_errors != [], do: "input-error", else: ""}"}
|
||||||
|
placeholder="your_handle"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p :for={error <- @field_errors} class="mt-1 text-xs text-error">
|
||||||
|
{translate_error(error)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def translate_error({msg, opts}) do
|
||||||
|
if count = opts[:count] do
|
||||||
|
Gettext.dngettext(MixerWeb.Gettext, "errors", msg, msg, count, opts)
|
||||||
|
else
|
||||||
|
Gettext.dgettext(MixerWeb.Gettext, "errors", msg, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
id="app"
|
id="app"
|
||||||
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
|
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
|
||||||
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
|
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
|
||||||
|
data-current-user-username={if @current_user, do: @current_user.username || "", else: ""}
|
||||||
|
data-current-user-display-name={
|
||||||
|
if @current_user, do: @current_user.display_name || "", else: ""
|
||||||
|
}
|
||||||
|
data-current-user-avatar-url={if @current_user, do: @current_user.avatar_url || "", else: ""}
|
||||||
data-asset-host={@media_host}
|
data-asset-host={@media_host}
|
||||||
data-page={@page}
|
data-page={@page}
|
||||||
data-tweet-id={@tweet_id || ""}
|
data-tweet-id={@tweet_id || ""}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ defmodule MixerWeb.UploadController do
|
|||||||
use MixerWeb, :controller
|
use MixerWeb, :controller
|
||||||
|
|
||||||
alias Mixer.Posts.MediaUploader
|
alias Mixer.Posts.MediaUploader
|
||||||
|
alias Mixer.Accounts.AvatarUploader
|
||||||
|
|
||||||
def create(conn, %{"file" => %Plug.Upload{} = upload}) do
|
def create(conn, %{"file" => %Plug.Upload{} = upload}) do
|
||||||
actor = conn.assigns[:current_user]
|
actor = conn.assigns[:current_user]
|
||||||
@@ -46,4 +47,48 @@ defmodule MixerWeb.UploadController do
|
|||||||
|> put_status(:bad_request)
|
|> put_status(:bad_request)
|
||||||
|> json(%{error: "no file provided"})
|
|> json(%{error: "no file provided"})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Avatar upload ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def upload_avatar(conn, %{"file" => %Plug.Upload{} = upload}) do
|
||||||
|
actor = conn.assigns[:current_user]
|
||||||
|
|
||||||
|
unless actor do
|
||||||
|
conn
|
||||||
|
|> put_status(:unauthorized)
|
||||||
|
|> json(%{error: "authentication required"})
|
||||||
|
else
|
||||||
|
scope = %{user_id: actor.id}
|
||||||
|
|
||||||
|
case AvatarUploader.store({upload, scope}) do
|
||||||
|
{:ok, _file_name} ->
|
||||||
|
# The thumb is always stored as avatars/:user_id/thumb.webp
|
||||||
|
thumb_key = "avatars/#{actor.id}/thumb.webp"
|
||||||
|
|
||||||
|
actor
|
||||||
|
|> Ash.Changeset.for_update(:update_avatar, %{avatar_url: thumb_key}, actor: actor)
|
||||||
|
|> Ash.update()
|
||||||
|
|> case do
|
||||||
|
{:ok, _user} ->
|
||||||
|
json(conn, %{success: true, avatarUrl: thumb_key})
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
conn
|
||||||
|
|> put_status(:unprocessable_entity)
|
||||||
|
|> json(%{success: false, error: inspect(error)})
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
conn
|
||||||
|
|> put_status(:unprocessable_entity)
|
||||||
|
|> json(%{success: false, error: reason})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def upload_avatar(conn, _params) do
|
||||||
|
conn
|
||||||
|
|> put_status(:bad_request)
|
||||||
|
|> json(%{error: "no file provided"})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
188
lib/mixer_web/live/magic_sign_in_live.ex
Normal file
188
lib/mixer_web/live/magic_sign_in_live.ex
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
defmodule MixerWeb.MagicSignInLive do
|
||||||
|
@moduledoc """
|
||||||
|
Custom magic-link sign-in LiveView that collects a username for new users.
|
||||||
|
|
||||||
|
When a user clicks their magic link, this page is shown instead of the
|
||||||
|
default auto-submit. If the user is brand new (no account) or has no
|
||||||
|
username set yet, we ask them to choose one before completing sign-in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use AshAuthentication.Phoenix.Overrides.Overridable,
|
||||||
|
root_class: "CSS class for the root `div` element.",
|
||||||
|
magic_sign_in_id: "Element ID for the `MagicSignIn` LiveComponent."
|
||||||
|
|
||||||
|
use AshAuthentication.Phoenix.Web, :live_view
|
||||||
|
|
||||||
|
alias AshAuthentication.Info
|
||||||
|
alias AshPhoenix.Form
|
||||||
|
alias Phoenix.LiveView.{Rendered, Socket}
|
||||||
|
|
||||||
|
import AshAuthentication.Phoenix.Components.Helpers, only: [auth_path: 5]
|
||||||
|
import PhoenixHTMLHelpers.Form, only: [hidden_input: 3, submit: 2]
|
||||||
|
import Slug
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def mount(params, session, socket) do
|
||||||
|
overrides =
|
||||||
|
session
|
||||||
|
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
|
||||||
|
|
||||||
|
resource = session["resource"]
|
||||||
|
strategy_name = session["strategy"]
|
||||||
|
token = params["token"] || params["magic_link"]
|
||||||
|
|
||||||
|
strategy = Info.strategy!(resource, strategy_name)
|
||||||
|
subject_name = Info.authentication_subject_name!(resource)
|
||||||
|
domain = Info.authentication_domain!(resource)
|
||||||
|
|
||||||
|
# Determine whether this user needs to pick a username
|
||||||
|
needs_username? = needs_username?(token, resource, domain)
|
||||||
|
|
||||||
|
form =
|
||||||
|
resource
|
||||||
|
|> Form.for_action(strategy.sign_in_action_name,
|
||||||
|
params: %{"token" => token},
|
||||||
|
domain: domain,
|
||||||
|
as: subject_name |> to_string(),
|
||||||
|
id: "#{subject_name}-#{strategy_name}-sign-in-form" |> slugify(),
|
||||||
|
context: %{strategy: strategy, private: %{ash_authentication?: true}}
|
||||||
|
)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(overrides: overrides)
|
||||||
|
|> assign(:token, token)
|
||||||
|
|> assign(:strategy, strategy)
|
||||||
|
|> assign(:subject_name, subject_name)
|
||||||
|
|> assign(:resource, resource)
|
||||||
|
|> assign(:needs_username?, needs_username?)
|
||||||
|
|> assign(:form, form)
|
||||||
|
|> assign(:trigger_action, false)
|
||||||
|
|> assign(:current_tenant, session["tenant"])
|
||||||
|
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec handle_params(map, String.t(), Socket.t()) :: {:noreply, Socket.t()}
|
||||||
|
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec render(Socket.assigns()) :: Rendered.t()
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="min-h-screen flex flex-col items-center justify-center bg-base-100 p-4">
|
||||||
|
<div class="w-full max-w-sm mb-8 text-center">
|
||||||
|
<.live_component
|
||||||
|
module={AshAuthentication.Phoenix.Components.Banner}
|
||||||
|
id="magic-sign-in-banner"
|
||||||
|
overrides={@overrides}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full max-w-sm p-6 bg-base-100 border border-base-200 rounded-xl shadow-sm">
|
||||||
|
<.form :let={form} for={@form} phx-change="validate" phx-submit="submit" phx-trigger-action={@trigger_action}
|
||||||
|
action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :sign_in)}
|
||||||
|
method="POST">
|
||||||
|
|
||||||
|
{hidden_input(form, :token, [])}
|
||||||
|
|
||||||
|
<%!-- Using the unified component --%>
|
||||||
|
<MixerWeb.AuthComponents.username_field :if={@needs_username?} form={form} />
|
||||||
|
|
||||||
|
{submit("Sign in", class: "btn btn-primary w-full", phx_disable_with: "Signing in...")}
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec handle_event(String.t(), map(), Socket.t()) :: {:noreply, Socket.t()}
|
||||||
|
def handle_event("submit", params, socket) do
|
||||||
|
subject_name =
|
||||||
|
socket.assigns.subject_name
|
||||||
|
|> to_string()
|
||||||
|
|> slugify()
|
||||||
|
|
||||||
|
form_params = Map.get(params, subject_name, %{})
|
||||||
|
|
||||||
|
# Use Form.validate with :all_errors to surface uniqueness constraints
|
||||||
|
form =
|
||||||
|
socket.assigns.form
|
||||||
|
|> Form.validate(form_params, errors: true)
|
||||||
|
|
||||||
|
if form.valid? do
|
||||||
|
# Only trigger the POST redirect if the data is truly valid
|
||||||
|
{:noreply, assign(socket, form: form, trigger_action: true)}
|
||||||
|
else
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(form: form, trigger_action: false)
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", params, socket) do
|
||||||
|
subject_name = socket.assigns.subject_name |> to_string() |> slugify()
|
||||||
|
form_params = Map.get(params, subject_name, %{})
|
||||||
|
|
||||||
|
form = Form.validate(socket.assigns.form, form_params, errors: true)
|
||||||
|
{:noreply, assign(socket, form: form)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Returns true if the user is new or has no username set yet.
|
||||||
|
defp needs_username?(nil, _resource, _domain), do: true
|
||||||
|
|
||||||
|
defp needs_username?(token, resource, domain) do
|
||||||
|
with {:ok, claims} <- AshAuthentication.Jwt.peek(token),
|
||||||
|
# 1. Try to find an existing user from the claims
|
||||||
|
user <- find_user(claims, resource, domain),
|
||||||
|
# 2. If a user exists, check if they already have a username
|
||||||
|
false <- is_nil(user) do
|
||||||
|
is_nil(user.username) or user.username == ""
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
# Unknown / new user — ask for username to be safe
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_user(claims, resource, domain) do
|
||||||
|
# Try 'sub' first if it looks like a user subject (e.g. "User:123")
|
||||||
|
sub = Map.get(claims, "sub")
|
||||||
|
|
||||||
|
user =
|
||||||
|
if is_binary(sub) and String.contains?(sub, ":") do
|
||||||
|
case AshAuthentication.subject_to_user(sub, resource) do
|
||||||
|
{:ok, user} -> user
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# If not found via subject, try 'identity' (common in magic link tokens)
|
||||||
|
user ||
|
||||||
|
case Map.get(claims, "identity") || Map.get(claims, "email") do
|
||||||
|
email when is_binary(email) ->
|
||||||
|
# Use for_read with the explicit action and arguments
|
||||||
|
resource
|
||||||
|
|> Ash.Query.for_read(:get_by_email, %{email: email})
|
||||||
|
|> Ash.read_one(domain: domain, authorize?: false)
|
||||||
|
|> case do
|
||||||
|
{:ok, user} -> user
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -47,6 +47,7 @@ defmodule MixerWeb.Router do
|
|||||||
post "/rpc/run", AshTypescriptRpcController, :run
|
post "/rpc/run", AshTypescriptRpcController, :run
|
||||||
post "/rpc/validate", AshTypescriptRpcController, :validate
|
post "/rpc/validate", AshTypescriptRpcController, :validate
|
||||||
post "/upload", UploadController, :create
|
post "/upload", UploadController, :create
|
||||||
|
post "/upload/avatar", UploadController, :upload_avatar
|
||||||
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
|
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
|
||||||
sign_out_route AuthController
|
sign_out_route AuthController
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ defmodule MixerWeb.Router do
|
|||||||
|
|
||||||
# Remove this if you do not use the magic link strategy.
|
# Remove this if you do not use the magic link strategy.
|
||||||
magic_sign_in_route(Mixer.Accounts.User, :magic_link,
|
magic_sign_in_route(Mixer.Accounts.User, :magic_link,
|
||||||
|
live_view: MixerWeb.MagicSignInLive,
|
||||||
auth_routes_prefix: "/auth",
|
auth_routes_prefix: "/auth",
|
||||||
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
defmodule Mixer.Repo.Migrations.AddUserProfileFields do
|
||||||
|
@moduledoc """
|
||||||
|
Updates resources based on their most recent snapshots.
|
||||||
|
|
||||||
|
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:users) do
|
||||||
|
add :username, :text
|
||||||
|
add :display_name, :text
|
||||||
|
add :avatar_url, :text
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:users, [:username], name: "users_unique_username_index")
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop_if_exists unique_index(:users, [:username], name: "users_unique_username_index")
|
||||||
|
|
||||||
|
alter table(:users) do
|
||||||
|
remove :avatar_url
|
||||||
|
remove :display_name
|
||||||
|
remove :username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
133
priv/resource_snapshots/repo/users/20260408035351.json
Normal file
133
priv/resource_snapshots/repo/users/20260408035351.json
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"gen_random_uuid()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "email",
|
||||||
|
"type": "citext"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "hashed_password",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "confirmed_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "username",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "display_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "avatar_url",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "E57BFA1141A2F4D237E6B3C8FE4BAD93772015179B56AEC9FA1F762C4FF5B6B8",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "users_unique_email_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "email"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_email",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "users_unique_username_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "username"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_username",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mixer.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "users"
|
||||||
|
}
|
||||||
@@ -93,11 +93,15 @@ defmodule Mixer.Posts.TweetLikeTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp user_fixture(email) do
|
defp user_fixture(email) do
|
||||||
|
username =
|
||||||
|
email |> String.split("@") |> List.first() |> String.replace(~r/[^a-zA-Z0-9_]/, "_")
|
||||||
|
|
||||||
User
|
User
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
email: email,
|
email: email,
|
||||||
password: "password1234",
|
password: "password1234",
|
||||||
password_confirmation: "password1234"
|
password_confirmation: "password1234",
|
||||||
|
username: username
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ defmodule MixerWeb.PageControllerTest do
|
|||||||
%{
|
%{
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
password: "Password1!",
|
password: "Password1!",
|
||||||
password_confirmation: "Password1!"
|
password_confirmation: "Password1!",
|
||||||
|
username: "testuser"
|
||||||
},
|
},
|
||||||
authorize?: false
|
authorize?: false
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user