import React, { createContext, useContext, useState, useRef, useEffect } from "react";
import { createRoot } from "react-dom/client";
import {
QueryClient,
QueryClientProvider,
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import {
createTweet,
readTweet,
destroyTweet,
updateTweet,
buildCSRFHeaders,
} from "./ash_rpc";
import { uploadFile } from "./upload";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 10_000 } },
});
// ── Types ──────────────────────────────────────────────────────────────────────
type MediaItem = { id: string; s3Key: string };
type Tweet = { id: string; content: string; userId: string; state: string; media?: MediaItem[] };
// ── Auth context ───────────────────────────────────────────────────────────────
const AuthCtx = createContext({ email: "", userId: "" });
// ── Helpers ────────────────────────────────────────────────────────────────────
function timeAgo(): string {
return "just now";
}
function getAssetHost(): string {
const appEl = document.getElementById("app");
return appEl?.dataset.assetHost ?? "http://localhost:3901";
}
// ── Components ─────────────────────────────────────────────────────────────────
function Spinner() {
return (
);
}
function ErrorBanner({ message }: { message: string }) {
return (
⚠
{message}
);
}
function CharCount({ current, max }: { current: number; max: number }) {
const remaining = max - current;
const pct = current / max;
const color =
pct > 0.9 ? "#ef4444" : pct > 0.75 ? "#f59e0b" : "var(--mx-muted)";
return (
{remaining}
);
}
function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
const [text, setText] = useState("");
const [error, setError] = useState(null);
const [pendingFile, setPendingFile] = 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 MAX = 280;
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"] });
setText("");
setError(null);
setMediaId(null);
setPendingFile(null);
setUploadError(null);
onSuccess?.();
},
onError: (e: Error) => setError(e.message),
});
async function handleFileChange(e: React.ChangeEvent) {
const file = e.target.files?.[0];
if (!file) return;
// Reset the input so the same file can be re-selected after removal
e.target.value = "";
setPendingFile(file);
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);
} else {
setMediaId(result.mediaId);
}
}
function removeAttachment() {
setPendingFile(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);
}
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [text]);
return (
);
}
function TweetMedia({ media }: { media: MediaItem[] }) {
const assetHost = getAssetHost();
return (
{media.map((m) =>
/\.(mp4|mov)$/i.test(m.s3Key) ? (
) : (

)
)}
);
}
function TweetCard({ tweet }: { tweet: Tweet }) {
const { userId: currentUserId } = useContext(AuthCtx);
const canModify = !!currentUserId && tweet.userId === currentUserId;
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(tweet.content);
const [error, setError] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const qc = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
const res = await destroyTweet({
identity: tweet.id,
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["tweets"] }),
onError: (e: Error) => setError(e.message),
});
const updateMutation = useMutation({
mutationFn: async (content: string) => {
const res = await updateTweet({
identity: tweet.id,
input: { content },
fields: ["id", "content", "userId", "state"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] });
setEditing(false);
setError(null);
},
onError: (e: Error) => setError(e.message),
});
function saveEdit() {
const trimmed = editText.trim();
if (!trimmed) return;
updateMutation.mutate(trimmed);
}
return (
M
@mixer
·
{timeAgo()}
{canModify && (
)}
{editing ? (
) : (
{tweet.content}
)}
{tweet.media && tweet.media.length > 0 && (
)}
{error && !editing &&
{error}
}
);
}
function Feed() {
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ["tweets"],
queryFn: async () => {
const res = await readTweet({
fields: ["id", "content", "userId", "state", { media: ["id", "s3Key"] }],
sort: "-id",
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load tweets");
const tweets = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return tweets as Tweet[];
},
});
if (isLoading) return ;
if (isError) {
return (
);
}
const tweets = data ?? [];
if (tweets.length === 0) {
return (
◎
Nothing posted yet
Be the first to mix something in.
);
}
return (
{tweets.map((t) => (
))}
);
}
function RefreshButton() {
const qc = useQueryClient();
const [spinning, setSpinning] = useState(false);
async function refresh() {
setSpinning(true);
await qc.invalidateQueries({ queryKey: ["tweets"] });
setTimeout(() => setSpinning(false), 600);
}
return (
);
}
function App() {
const appEl = document.getElementById("app")!;
const email = appEl.dataset.currentUserEmail ?? "";
const userId = appEl.dataset.currentUserId ?? "";
return (
About Mixer
A minimal social feed built with Ash Framework, Phoenix, and React.
{["Ash 3", "Phoenix 1.8", "AshTypescript", "React 19"].map((s) => (
{s}
))}
);
}
// ── Bootstrap ──────────────────────────────────────────────────────────────────
const root = createRoot(document.getElementById("app")!);
root.render(
);