From 90d7eab7d0d5afb6f5d8bf70dd7c857c626b4b63 Mon Sep 17 00:00:00 2001 From: qdust41 Date: Wed, 8 Apr 2026 02:03:43 -0400 Subject: [PATCH] some ai generated code from claude that does not work --- assets/css/app.css | 153 ++++++++ assets/js/ash_rpc.ts | 77 ++++ assets/js/ash_types.ts | 65 +++- assets/js/index.tsx | 347 +++++++++++++++--- assets/js/upload.ts | 23 ++ lib/mixer/accounts.ex | 1 + lib/mixer/accounts/avatar_uploader.ex | 33 ++ lib/mixer/accounts/user.ex | 85 +++++ lib/mixer/posts/tweet.ex | 12 + lib/mixer_web/auth_overrides.ex | 5 + lib/mixer_web/components/auth_components.ex | 51 +++ .../controllers/page_html/index.html.heex | 5 + .../controllers/upload_controller.ex | 45 +++ lib/mixer_web/live/magic_sign_in_live.ex | 184 ++++++++++ lib/mixer_web/router.ex | 2 + ...20260408035350_add_user_profile_fields.exs | 29 ++ .../repo/users/20260408035351.json | 133 +++++++ test/mixer/posts/tweet_like_test.exs | 6 +- .../controllers/page_controller_test.exs | 3 +- 19 files changed, 1206 insertions(+), 53 deletions(-) create mode 100644 lib/mixer/accounts/avatar_uploader.ex create mode 100644 lib/mixer_web/components/auth_components.ex create mode 100644 lib/mixer_web/live/magic_sign_in_live.ex create mode 100644 priv/repo/migrations/20260408035350_add_user_profile_fields.exs create mode 100644 priv/resource_snapshots/repo/users/20260408035351.json diff --git a/assets/css/app.css b/assets/css/app.css index 2d465c8..e4efb7e 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1056,6 +1056,159 @@ html, body { .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 */ @media (max-width: 640px) { .mx-feed { padding: 0.625rem; gap: 0.5rem; } diff --git a/assets/js/ash_rpc.ts b/assets/js/ash_rpc.ts index 5296cd4..88a8d32 100644 --- a/assets/js/ash_rpc.ts +++ b/assets/js/ash_rpc.ts @@ -541,6 +541,83 @@ export async function validateReadUser( } +export type UpdateProfileInput = { + username?: string | null; + displayName?: string | null; +}; + +export type UpdateProfileFields = UnifiedFieldSelection[]; + +export type InferUpdateProfileResult< + Fields extends UpdateProfileFields | undefined, +> = InferResult; + +export type UpdateProfileResult = | { success: true; data: InferUpdateProfileResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Update an existing User + * + * @ashActionType :update + */ +export async function updateProfile( + config: { + tenant?: string; + identity: UUID; + input?: UpdateProfileInput; + fields?: Fields; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise> { + 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>( + 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; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "update_profile", + ...(config.tenant !== undefined && { tenant: config.tenant }), + identity: config.identity, + input: config.input + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + export type ReadMediaFields = UnifiedFieldSelection[]; diff --git a/assets/js/ash_types.ts b/assets/js/ash_types.ts index 6f2a25e..531230d 100644 --- a/assets/js/ash_types.ts +++ b/assets/js/ash_types.ts @@ -25,9 +25,12 @@ export type followsAttributesOnlySchema = { // users Schema export type usersResourceSchema = { __type: "Resource"; - __primitiveFields: "id" | "email" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId"; + __primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId"; id: UUID; email: string; + username: string | null; + displayName: string | null; + avatarUrl: string | null; followerCount: number; followingCount: number; amIFollowing: boolean; @@ -38,9 +41,12 @@ export type usersResourceSchema = { export type usersAttributesOnlySchema = { __type: "Resource"; - __primitiveFields: "id" | "email"; + __primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl"; id: UUID; email: string; + username: string | null; + displayName: string | null; + avatarUrl: string | null; }; @@ -71,7 +77,7 @@ export type mediaAttributesOnlySchema = { // tweets Schema export type tweetsResourceSchema = { __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; content: string; likes: number; @@ -82,6 +88,9 @@ export type tweetsResourceSchema = { commentCount: number; likedByMe: boolean; userEmail: string | null; + userUsername: string | null; + userDisplayName: string | null; + userAvatarUrl: string | null; user: { __type: "Relationship"; __resource: usersResourceSchema; }; parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; }; comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; }; @@ -134,6 +143,27 @@ export type usersFilterInput = { in?: Array; }; + username?: { + eq?: string; + notEq?: string; + in?: Array; + isNil?: boolean; + }; + + displayName?: { + eq?: string; + notEq?: string; + in?: Array; + isNil?: boolean; + }; + + avatarUrl?: { + eq?: string; + notEq?: string; + in?: Array; + isNil?: boolean; + }; + followerCount?: { eq?: number; notEq?: number; @@ -270,6 +300,27 @@ export type tweetsFilterInput = { isNil?: boolean; }; + userUsername?: { + eq?: string; + notEq?: string; + in?: Array; + isNil?: boolean; + }; + + userDisplayName?: { + eq?: string; + notEq?: string; + in?: Array; + isNil?: boolean; + }; + + userAvatarUrl?: { + eq?: string; + notEq?: string; + in?: Array; + isNil?: boolean; + }; + commentCount?: { eq?: number; notEq?: number; @@ -301,26 +352,26 @@ export type tweetsFilterInput = { export const followsFilterFields = ["id"] as const; 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 const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const; 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 const followsSortFields = ["id"] as const; 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 const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const; 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]; diff --git a/assets/js/index.tsx b/assets/js/index.tsx index 1c7d500..19c5d29 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -17,12 +17,13 @@ import { likeTweet, unlikeTweet, updateTweet, + updateProfile, readUser, followUser, unfollowUser, buildCSRFHeaders, } from "./ash_rpc"; -import { uploadFile } from "./upload"; +import { uploadFile, uploadAvatar } from "./upload"; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } }, @@ -33,6 +34,9 @@ const queryClient = new QueryClient({ type User = { id: string; email: string; + username?: string | null; + displayName?: string | null; + avatarUrl?: string | null; followerCount?: number; followingCount?: number; amIFollowing?: boolean; @@ -50,12 +54,21 @@ type Tweet = { state: string; media?: MediaItem[]; userEmail?: string | null; + userUsername?: string | null; + userDisplayName?: string | null; + userAvatarUrl?: string | null; insertedAt?: string | null; }; // ── Auth context ─────────────────────────────────────────────────────────────── -const AuthCtx = createContext({ email: "", userId: "" }); +const AuthCtx = createContext({ + email: "", + userId: "", + username: "", + displayName: "", + avatarUrl: "", +}); // ── Responsive helper ───────────────────────────────────────────────────────── // 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"; } +// ── 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 ( +
+ {avatarUrl ? ( + {name + ) : ( + {initial} + )} +
+ ); +} + // ── Context menu ────────────────────────────────────────────────────────────── type ContextMenuItem = @@ -203,6 +264,7 @@ function CharCount({ current, max }: { current: number; max: number }) { } function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) { + const { username, displayName, email, avatarUrl } = useContext(AuthCtx); const [text, setText] = useState(""); const [error, setError] = useState(null); const [pendingFile, setPendingFile] = useState(null); @@ -304,9 +366,7 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) { return (
-
- M -
+