diff --git a/assets/js/App.tsx b/assets/js/App.tsx new file mode 100644 index 0000000..8d4aa8a --- /dev/null +++ b/assets/js/App.tsx @@ -0,0 +1,200 @@ +import React, { useState } from "react"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { AuthCtx } from "./context"; +import { useIsDesktop } from "./hooks"; +import { ComposeTweet } from "./components/compose"; +import { Feed, FollowingFeed, RefreshButton } from "./components/feed"; +import { TweetDetail } from "./components/tweet-detail"; +import { UserList, UserDetail } from "./components/users"; +import { MyProfile } from "./components/profile"; +import { MobileNav, MobileComposePage } from "./components/nav"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 10_000 } }, +}); + +export function App() { + const appEl = document.getElementById("app")!; + const email = appEl.dataset.currentUserEmail ?? ""; + 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 page = appEl.dataset.page ?? "feed"; + const profileUserId = appEl.dataset.userId || null; + + const [mobileCompose, setMobileCompose] = useState(false); + const isDesktop = useIsDesktop(); + + const onFeedPage = page === "feed" || page === "tweet"; + const onFollowingPage = page === "following"; + const onUsersPage = page === "users" || page === "user-detail"; + const onProfilePage = page === "profile"; + + function renderMain() { + switch (page) { + case "tweet": + return ( + <> +
+

Tweet

+
+ + + ); + case "following": + return ( + <> +
+

Following

+ +
+ + + ); + case "users": + return ( + <> +
+

Users

+
+ + + ); + case "user-detail": + return ( + <> +
+

Profile

+
+ + + ); + case "profile": + return ( + <> +
+

My Profile

+
+ + + ); + default: + return ( + <> +
+

Feed

+ +
+ +
+ {email ? ( + + ) : ( +
+

Sign in to start mixing.

+ Sign in +
+ )} +
+ +
+ + + + ); + } + } + + return ( + + +
+ {isDesktop && ( + + )} + +
+ {renderMain()} +
+ + {isDesktop && ( +
+
+

About Mixer

+

+ A minimal social feed built with Ash Framework, Phoenix, and React. +

+
+ {["Ash 3", "Phoenix 1.8", "AshTypescript", "React 19"].map((s) => ( + {s} + ))} +
+
+
+ )} +
+ + setMobileCompose(true)} /> + + {mobileCompose && ( + setMobileCompose(false)} + /> + )} +
+
+ ); +} diff --git a/assets/js/components/compose.tsx b/assets/js/components/compose.tsx new file mode 100644 index 0000000..49b2baf --- /dev/null +++ b/assets/js/components/compose.tsx @@ -0,0 +1,294 @@ +import React, { useState, useRef, useEffect, useContext } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createTweet, buildCSRFHeaders } from "../ash_rpc"; +import { uploadFile } from "../upload"; +import { AuthCtx } from "../context"; +import { Avatar, CharCount } from "./ui"; + +const MAX = 280; + +export 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); + const [previewUrl, setPreviewUrl] = useState(null); + const [mediaId, setMediaId] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const qc = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async (content: string) => { + const res = await createTweet({ + input: { content, mediaId: mediaId ?? undefined }, + fields: ["id", "content", "userId", "state"], + headers: buildCSRFHeaders(), + }); + if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed"); + return res.data; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["tweets"] }); + qc.invalidateQueries({ queryKey: ["following_tweets"] }); + setText(""); + setError(null); + setMediaId(null); + setPendingFile(null); + setUploadError(null); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + setPreviewUrl(null); + } + onSuccess?.(); + }, + onError: (e: Error) => setError(e.message), + }); + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ""; + if (previewUrl) URL.revokeObjectURL(previewUrl); + const localUrl = URL.createObjectURL(file); + setPendingFile(file); + setPreviewUrl(localUrl); + setMediaId(null); + setUploadError(null); + setUploading(true); + const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string; + const result = await uploadFile(file, csrfToken); + setUploading(false); + if ("error" in result) { + setUploadError(result.error); + setPendingFile(null); + URL.revokeObjectURL(localUrl); + setPreviewUrl(null); + } else { + setMediaId(result.mediaId); + } + } + + function removeAttachment() { + if (previewUrl) URL.revokeObjectURL(previewUrl); + setPendingFile(null); + setPreviewUrl(null); + setMediaId(null); + setUploadError(null); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + submit(); + } + } + + function submit() { + const trimmed = text.trim(); + if (!trimmed) return; + if (trimmed.length > MAX) { + setError(`Max ${MAX} characters`); + return; + } + setError(null); + mutation.mutate(trimmed); + } + + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [text]); + + return ( +
+ +
+